diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4499e3fdc..2a8144a5b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,7 +29,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: effectcraft app-dir: EffectCraft diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 77a9cc432..c97d32ed9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -89,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall `crates/engine/presets/transitions.json` (sidecar `.attribution`). - Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`). +- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker): + `crates/engine/presets/behaviors.json` (sidecar `.attribution`). - Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..74d946f01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. +On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA +pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime +library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile +ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking +error; `cargo xtask ci` probes pkg-config up front and names the package: + +```sh +sudo dnf install alsa-lib-devel # Fedora +sudo apt install libasound2-dev # Debian / Ubuntu +sudo pacman -S alsa-lib # Arch +``` + +If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc` +whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs +that variable set. + ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace cargo xtask ci # what every commit must pass ``` +The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces +still name the file and line, but a debugger cannot show local variables. For an interactive +debugger session use the `dev-full` profile, which is the same build with full debug info: + +```sh +cargo run --profile dev-full -p effectcraft -- --demo +``` + ## Ground rules - **Clean room.** Work from After Effects' public documentation and its observable behaviour only. diff --git a/Cargo.lock b/Cargo.lock index 52812471c..629fc65e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,7 @@ name = "effectcraft-media" version = "0.18.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -1632,18 +1633,20 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "filmcraft-aac", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", "filmcraft-isobmff", + "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpegts", "filmcraft-time", "gif", "image", "log", "rayon", "serde_json", - "symphonia", "thiserror 2.0.21", ] @@ -1653,6 +1656,7 @@ version = "0.18.0" dependencies = [ "effectcraft-geom", "image", + "log", "miniz_oxide 0.9.1", "serde", "serde_json", @@ -2244,8 +2248,8 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2253,40 +2257,57 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] -name = "filmcraft-av1" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +name = "filmcraft-apv" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-av1" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + +[[package]] +name = "filmcraft-avi" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" + [[package]] name = "filmcraft-bitstream" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-cfb" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-codecs" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-aac", "filmcraft-ac3", + "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -2296,6 +2317,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2303,9 +2325,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2313,16 +2337,25 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2331,8 +2364,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2342,16 +2375,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2360,8 +2393,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2370,8 +2403,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2380,8 +2413,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2397,8 +2430,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2406,15 +2439,16 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-media" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", @@ -2423,10 +2457,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2434,31 +2477,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-ogg" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-opus" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-project" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2471,8 +2514,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2481,16 +2524,24 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-vp8" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -7198,6 +7249,7 @@ name = "xtask" version = "0.18.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index aa917fc63..c101c489c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,29 +77,29 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +# container headers read for AAC channel layouts (crates/media) +filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned -# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. -symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } @@ -131,6 +131,8 @@ sys-locale = "0.3" # filmcraft-prores = { path = "../filmcraft/crates/prores" } # filmcraft-aac = { path = "../filmcraft/crates/aac" } # filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" } +# filmcraft-matroska = { path = "../filmcraft/crates/matroska" } +# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" } [workspace.lints.rust] unsafe_code = "forbid" @@ -153,6 +155,13 @@ opt-level = 1 # Panics and backtraces keep file and line; full debug info only made the many test binaries large. debug = "line-tables-only" +# Same settings as `dev`, but with full debug info for interactive debugger sessions: local +# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging; +# everything else stays on the fast default. See CONTRIBUTING.md, "Setup". +[profile.dev-full] +inherits = "dev" +debug = true + # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] opt-level = 3 diff --git a/README.md b/README.md index 81d42360c..12970b8ba 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

A free, open-source compositor: compositions, layers, - keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, + keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, Windows and Linux, and in the browser. Young, moving fast, and already usable.

@@ -84,9 +84,10 @@ a few places where it matters to us: without a plugin. - **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly in version control. -- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one - command registry, so the same actions are reachable from a command line, a JSON control - channel and an MCP server for agents. +- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as + commands. Engine commands are available through the CLI, JSON control channel and MCP; + UI-only commands use the frontend dispatcher and can be invoked through the control channel + or an attached MCP bridge. - **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and EffectCraft's own VP9, AV1, HEVC and Opus encoders. @@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 built-in effects across its categories, each with its parameter names, order and +308 built-in effects across its categories, each with its parameter names, order and defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, @@ -155,7 +156,7 @@ tools, and Front, Top, Left and Custom views. A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and **ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1** -MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field +MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and resize, Render Settings and Output Module templates with defaults, post-render actions, storage overflow and render logs. The same queue runs from the command line. Every encoder is pure @@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. ## Built for agents -Everything you can do from a menu is a command with an id, and agents can reach every one of -them: +Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands +need the running app's frontend dispatcher: complete menu access requires the app's control +channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands, +but not UI-only commands or bridge-only `ui_*` tools. - **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property @@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set -exists, including 306 effects, but breadth is not the same as full maturity across every edge +exists, including 308 effects, but breadth is not the same as full maturity across every edge case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended for production client work. In particular: diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..740ec6010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | +| Spanish | menus only | 29% | | Hindi | none | 0% | | Arabic | none | 0% | | French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 34% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | +| Japanese | partial (menus, most of the panels) | 46% | | German | menus only | 16% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had native-speaker review. ## Upcoming @@ -117,10 +117,33 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel, + Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items + drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one + animation preset can hold several (#2552). The Composition panel pans while a mask vertex is + held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget + (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick + between frames (#2593). ScriptUI drags keep their control past the window edge with + `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585). +- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain + that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it + starts off, so existing shapes behave as before (#2489). +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or + `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and + rotation of the paths above it by random amounts that change over time, up to the Transform + values it holds. The copies of a Repeater above it wiggle independently as far as its + Correlation allows; with the Repeater below it, they wiggle together. +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774: + Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese + panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is + 46% with its 307 effect names; the overall share is ≈ 24%. - 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-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 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 @@ -135,7 +158,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. -- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. @@ -155,11 +177,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size | +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | -| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | -| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6e5b8d6cc..db6394b21 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -7,11 +7,12 @@ //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order //! effectcraft-cli run --args-file F the same, one argument per line of F +//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] //! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent] -//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S] +//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S] //! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba] //! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] @@ -98,6 +99,10 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order run --args-file F the same, one argument per line of F (no command-line length limit) + run --commands FILE|- steps from FILE, or standard input for -: a JSON array of + {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one + such object per line (JSON Lines); at most 64 MiB and 100000 steps. + Repeatable; run after the pairs and --args-file props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -107,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli ) -> Result raw, Err(e) => fail_usage(&e), @@ -334,6 +343,87 @@ impl From for Failure { } } +/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input. +const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024; +/// Most steps one `run --commands` takes. +const MAX_COMMANDS_STEPS: usize = 100_000; + +/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past +/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from +/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum. +fn read_commands(source: &str) -> Result { + use std::io::Read; + let mut bytes = vec![]; + let read = if source == "-" { + std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes) + } else { + std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)) + }; + read.map_err(|e| format!("--commands {source}: {e}"))?; + if bytes.len() as u64 > MAX_COMMANDS_BYTES { + return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024))); + } + String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text")) +} + +/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params": +/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank +/// lines skipped). `first` is the run's index of the first of them, which an error names with the +/// entry's place in the text. +fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> { + // Notepad on Windows starts UTF-8 files with a byte order mark. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps"); + // Each entry with where it is in the text: its line for JSON Lines. + let mut entries: Vec<(Option, Value)> = vec![]; + if text.trim_start().starts_with('[') { + let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?; + let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) }; + if items.len() > MAX_COMMANDS_STEPS { + return Err(too_many()); + } + entries.extend(items.into_iter().map(|v| (None, v))); + } else { + for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + if entries.len() == MAX_COMMANDS_STEPS { + return Err(too_many()); + } + let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?; + entries.push((Some(n + 1), v)); + } + } + entries + .into_iter() + .enumerate() + .map(|(k, (line, v))| { + command_step(v).map_err(|e| { + let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}")); + format!("--commands {source}: step {} ({place}): {e}", first + k) + }) + }) + .collect() +} + +/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`. +fn command_step(v: Value) -> Result<(String, Value), String> { + let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) }; + let id = match (o.remove("command"), o.remove("id")) { + (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id, + (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()), + (None, None) => return Err("no `command`".into()), + _ => return Err("`command` must be a string, a command id".into()), + }; + let params = match o.remove("params") { + None | Some(Value::Null) => json!({}), + Some(p @ Value::Object(_)) => p, + Some(_) => return Err(format!("`params` of {id} must be a JSON object")), + }; + if let Some(key) = o.keys().next() { + return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`")); + } + Ok((id, params)) +} + fn usage_err(m: &str) -> Result { Err(Failure::Usage(m.to_string())) } @@ -524,14 +614,14 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); } - if pairs.is_empty() { - return usage_err("run needs [] pairs"); + let sources = args.opts("--commands"); + if pairs.is_empty() && sources.is_empty() { + return usage_err("run needs [] pairs or --commands FILE|-"); } - let mut b = backend(args, true)?; - let mut results = vec![]; + let mut steps = vec![]; let mut i = 0; while i < pairs.len() { - let id = &pairs[i]; + let id = pairs[i].clone(); let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) { Some(p) => { i += 1; @@ -540,9 +630,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { None => json!({}), }; i += 1; - let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; - if let Some(e) = failed_render(id, &r) { - return Err(Failure::Error(format!("{id}: {e}"))); + steps.push((id, params)); + } + if sources.iter().filter(|s| **s == "-").count() > 1 { + return usage_err("--commands - is given twice: standard input is read once"); + } + for &source in &sources { + let text = read_commands(source).map_err(Failure::Usage)?; + steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?); + } + if steps.is_empty() { + return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", ")))); + } + let mut b = backend(args, true)?; + let mut results = vec![]; + // The step's index closes the message: a generated list repeats ids, and callers match + // the leading `: `. + for (n, (id, params)) in steps.into_iter().enumerate() { + let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?; + if let Some(e) = failed_render(&id, &r) { + return Err(Failure::Error(format!("{id}: {e} (step {n})"))); } if !json_out { emit(&r, false); @@ -1189,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option (i32, String, String) { + let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + (out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON +/// Lines, from a file or standard input, and runs them as the same positional pairs would: after +/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are +/// over Linux's 128 KiB per-argument limit goes through. +#[test] +fn run_reads_commands_from_a_file_or_stdin() { + let steps = [ + ("comp.new", json!({"name": "A", "width": 64, "height": 64})), + ("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})), + ("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})), + ("project.summary", json!({})), + ]; + let pairs: Vec = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect(); + let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect(); + let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat()); + let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64(); + assert_eq!(layers(&expected), Some(2), "{expected}"); + + let objects: Vec = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect(); + let array = tmp("commands.json"); + std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap(); + assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected); + + let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect(); + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines); + assert_eq!(code, 0, "{err}"); + assert_eq!(serde_json::from_str::(&out).unwrap(), expected); + + // Positional pairs first, then the file. + let rest = tmp("commands-rest.jsonl"); + std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::()).unwrap(); + assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected); + + let name = "n".repeat(140 * 1024); + let big = tmp("commands-big.jsonl"); + std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap(); + let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]); + assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len())); + + for (input, args, error) in [ + ("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"), + ( + "{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n", + &["run", "project.summary", "{}", "--commands", "-", "--empty"], + "step 2 (line 2): unknown key `param`", + ), + ("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"), + ("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"), + ] { + let (code, out, err) = run_with_stdin(args, input); + assert_eq!(code, 2, "{input:?}: {err}"); + assert!(err.contains(error), "{input:?}: {err}"); + assert!(out.is_empty(), "{input:?}: nothing ran"); + } + // An empty list after positional pairs is no error: there is still something to run. + let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], ""); + assert_eq!(code, 0, "{err}"); + // A step that fails when it runs is named by its index too. + let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n"); + assert_eq!(code, 1, "{err}"); + let e = serde_json::from_str::(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default(); + assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}"); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 9346acc29..c8e91a260 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -7,6 +7,23 @@ // // Plain functions only: the Rust side owns the logic. +// ------------------------------------------------------------------ shortcuts + +/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks. +/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because +/// eframe falls back to the key code there; it does not on Dvorak, where it types E. +/// eframe still receives the key; other chords and HTML inputs keep their browser behavior. +export function installCanvasShortcuts(canvas) { + canvas.addEventListener("keydown", (event) => { + if (event.target === canvas && (event.ctrlKey || event.metaKey) + && !event.shiftKey && !event.altKey && !event.isComposing + && (event.key.toLowerCase() === "d" + || (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) { + event.preventDefault(); + } + }, { capture: true }); +} + // ------------------------------------------------------------------ storage const DIR = "effectcraft"; diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 169529e5d..737f59f6a 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::startup::Startup; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*; extern "C" { #[wasm_bindgen(js_name = showDeviceLost)] fn show_device_lost(message: &str, kept: bool); + #[wasm_bindgen(js_name = installCanvasShortcuts)] + fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement); } /// Work for the UI thread with access to the app (posted by async tasks: picked files…). @@ -81,8 +84,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -115,6 +122,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -204,6 +216,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; + install_canvas_shortcuts(&canvas); let empty = query_flag("empty"); let home = query_flag("home"); // Browser storage: settings, recent projects, imported media, saved projects, auto-saves and @@ -220,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); + // `./effectcraft-config.json`: what to open on load. + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -245,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -254,11 +272,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); @@ -284,7 +305,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..614d7d4cb --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,73 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). +async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..4759096f3 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,55 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 8aafdac1c..c5c4ada94 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -366,6 +366,21 @@ impl ConfigStore for WebConfig { v.sort(); v } + /// The first name part of the keys under `dir/`. + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/')); + let mut v: Vec = self + .store + .lock() + .list(&pre) + .into_iter() + .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string())) + .filter(|d| !d.is_empty()) + .collect(); + v.sort(); + v.dedup(); + v + } fn dir(&self) -> Option { Some(PathBuf::from("/")) } @@ -533,6 +548,22 @@ mod tests { assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..])); } + /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop. + #[test] + fn config_store_lists_folders() { + let c = WebConfig::new(store()); + for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] { + c.write(name, "x").unwrap(); + } + assert_eq!(c.list("Scripts"), ["Top.jsx"]); + assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]); + assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]); + let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() }; + let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap(); + let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect(); + assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]); + } + #[test] fn config_store_and_file_ops() { let s = store(); diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..3ce90569c --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,52 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +/// The three accepted values parse. +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +/// Other keys do not hide `startup`. +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +/// A file over `CONFIG_MAX` is ignored. +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +/// Which project each value restores or opens. +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 063015236..3728e59e2 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -8,6 +8,8 @@ // - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; // the reload writes pending changes to browser storage first, and a failed write is reported // instead of reloading (#225). +// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering +// the key to the app; other chords and HTML inputs keep their browser behavior (#2495). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; @@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); +// #2495: prevent the browser default without swallowing the app's Duplicate key. +const canvas = new EventTarget(); +const input = new EventTarget(); +host.installCanvasShortcuts(canvas); +let delivered = 0; +canvas.addEventListener("keydown", () => delivered++); +const key = (target, properties) => { + const event = new Event("keydown", { cancelable: true }); + Object.assign(event, { key: "d", ...properties }); + target.dispatchEvent(event); + return event.defaultPrevented; +}; +assert.equal(key(canvas, { ctrlKey: true }), true); +assert.equal(key(canvas, { metaKey: true, key: "D" }), true); +for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) { + assert.equal(key(canvas, properties), false); +} +// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D. +assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true); +// Dvorak: the physical D key types "e", which is not Duplicate. +assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false); +assert.equal(delivered, 9, "all keys still reach the app"); +assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut"); + // #226: one readable and one unreadable file. const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); const picked = host.browsePickFiles(false); diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 64ab5443d..bdbdcda94 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -263,6 +263,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -378,9 +381,15 @@ fn main() -> eframe::Result { /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to /// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +/// +/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one +/// frame in flight, so a drag or a scrub shows one frame sooner. The bare +/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three +/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms +/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window. fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; - let mut config = eframe::WgpuConfiguration::default(); + let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { if let Some(b) = backends { setup.instance_descriptor.backends = b; @@ -489,6 +498,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { @@ -567,6 +578,19 @@ mod tests { assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } + /// The window's surface keeps one frame in flight, like eframe's own default, not the two + /// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag). + #[test] + fn the_window_surface_is_low_latency() { + for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] { + let surface = super::wgpu_options(backends).surface; + assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}"); + assert_eq!(surface.desired_maximum_frame_latency, Some(1)); + assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync); + } + assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface"); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { diff --git a/contributors/people.toml b/contributors/people.toml index 1012a37bd..59365207a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,11 +17,19 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.Krueels] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.lewandowskit] +real_name = "Tomasz Lewandowski" +display_name = "Tomasz Lewandowski" +sync_github_name = false + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" @@ -98,3 +106,11 @@ sync_github_name = false [people.ki2pixel] display_name = "ki2pixel" + +[people.becauseno7] +real_name = "Shuvra Basak" +display_name = "Shuvra Basak" +sync_github_name = false + +[people.ashokram-si] +real_name = "Ashok Ram" diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..06b83a880 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -7,7 +7,9 @@ //! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, //! and commands that write to a file they are not given (Save, Increment and Save, rendering the //! Render Queue) are checked against where they would write. Commands that run code or open -//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. +//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File, +//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network +//! access the user withheld. use std::path::{Component, Path, PathBuf}; @@ -28,13 +30,21 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", + // A ScriptUI panel's script runs when the panel opens, when an event reaches one of its + // controls, and when one of its `app.scheduleTask` tasks comes due. + "window.scriptPanel", + "window.restoreScriptPanels", + "script.runTasks", + "scriptui.click", + "scriptui.set", + "scriptui.mouse", + "scriptui.close", "file.executeFile", "file.revealInFinder", "layer.openSource", @@ -64,10 +74,29 @@ const READS: &[(&str, &[&str])] = &[ ("effect.plugins.load", &["path", "folder"]), ("mediaBrowser.go", &["path"]), ("mediaBrowser.addFavorite", &["path"]), + // #1243: these read the files they are given but were missing, so a relative path went + // unchecked. + ("essential.templateInfo", &["path"]), + ("file.importProject", &["path"]), + ("file.watchFolder.poll", &["folder"]), + ("footage.relink", &["path"]), + ("layer.newModel", &["path"]), + ("mediaBrowser.fileInfo", &["path"]), + ("mediaBrowser.list", &["path"]), + ("mediaBrowser.import", &["paths"]), + ("mediaBrowser.removeFavorite", &["path"]), + ("face.model.install", &["path"]), + ("roto.model.install", &["path"]), + // Reads the template at `path`; what it writes is in `MIXED_WRITES`. + ("templates.create", &["path"]), ]; +/// Commands that read some parameters (listed in [`READS`]) and write others. +const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])]; + /// Commands that write files named by these parameters. const WRITES: &[(&str, &[&str])] = &[ + ("camera.exportBlender", &["path"]), ("file.save", &["path"]), ("file.saveAs", &["path"]), ("file.saveCopy", &["path"]), @@ -84,6 +113,9 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), + ("file.exportTimeline", &["path"]), + // `path` / `output` may also be a file-name template, as for render.preRender. + ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. @@ -135,16 +167,26 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } + // Scripts can't grant themselves the access the user withheld + // (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined + // agent: `prefs.set` saves the settings file, so a script run long after this session + // would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.) + if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) { + return Err(Error::BadArgs( + "`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(), + )); + } if id == "engine.batch" { for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. - if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } if self.check_command(inner, &inner_params)?.is_some() { @@ -170,7 +212,7 @@ impl Confinement { for p in paths { // A Render Queue output may name a template instead of a file. match p.as_str() { - Some(s) if id == "render.preRender" && !looks_like_path(s) => {} + Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {} Some(s) => { self.check(s, access)?; } @@ -178,9 +220,18 @@ impl Confinement { } } } + // Parameters of a reading command that it writes to. + let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]); + for key in mixed { + if let Some(p) = params.get(*key).filter(|v| !v.is_null()) { + let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?; + self.check(p, Access::Write)?; + } + } // Any other absolute path the command is given. + let named: Vec<&str> = named.iter().chain(mixed).copied().collect(); let mut other = vec![]; - absolute_strings(params, named, &mut other); + absolute_strings(params, &named, &mut other); for s in other { self.check(&s, access)?; } @@ -218,6 +269,22 @@ impl Confinement { } } +/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON +/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as +/// the dotted spelling, and the page on its own replaces every setting on it. +fn is_scripting_pref(key: &str) -> bool { + key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting")) +} + +/// The settings `prefs.set` is given: `key`, and the keys of `values`. +fn prefs_keys(params: &Value) -> Vec<&str> { + let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect(); + if let Some(Value::Object(m)) = params.get("values") { + keys.extend(m.keys().map(String::as_str)); + } + keys +} + fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> { table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) } @@ -274,3 +341,115 @@ fn resolve(p: &Path) -> Option { at = at.parent()?; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Commands whose parameters are named like files but aren't: property paths + /// (`path|prop`), project folders, layers to track or target. + const NOT_FILES: &[&str] = &[ + "camera.createFromSolve", + "edit.label", + "edit.pasteReversedKeyframes", + "effect.editDropdown", + "essential.addProperty", + "essential.canAdd", + "essential.linkProperty", + "essential.unlinkProperty", + "keys.paste", + "keys.set", + "keys.setEase", + "keys.setSpatialTangents", + "paths.nullsFollowPoints", + "paths.pointsFollowNulls", + "paths.tracePath", + "project.move", + "prop.addKey", + "prop.convertExpressionToKeyframes", + "prop.deselect", + "prop.editGradient", + "prop.get", + "prop.pickWhip", + "prop.reset", + "prop.select", + "prop.set", + "prop.setExpression", + "prop.toggleAnimation", + "prop.toggleKey", + "track.new", + "track.property", + "track.setTarget", + ]; + + /// Every engine command with a parameter named like a file is classified, so a new one + /// can't silently skip confinement (#1243): it reads or writes the files it is given, is + /// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs + /// (checked when the queue renders), or is listed in `NOT_FILES`. + #[test] + fn every_command_with_file_parameters_is_classified() { + const FILE_KEYS: &[&str] = + &["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"]; + let mut missing = vec![]; + for spec in effectcraft_engine::command_specs() { + let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue }; + let id = spec.id; + let classified = keys_of(READS, id).is_some() + || keys_of(WRITES, id).is_some() + || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) + || IMPLICIT_WRITES.contains(&id) + || id.starts_with("renderQueue.") + || id == "render.addOutputModule" + || NOT_FILES.contains(&id); + if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified { + missing.push(format!("{id} {}", spec.params)); + } + } + assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n")); + // And every listed command exists. + for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) { + assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command"); + } + } + + #[test] + fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + let up = format!("{}/../elsewhere/t.ectemplate", read.display()); + for (id, key) in [ + ("essential.templateInfo", "path"), + ("file.importProject", "path"), + ("footage.relink", "path"), + ("layer.newModel", "path"), + ("mediaBrowser.fileInfo", "path"), + ("mediaBrowser.list", "path"), + ("face.model.install", "path"), + ("templates.create", "path"), + ] { + for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] { + assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}"); + } + assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root"); + } + // Batched too. + let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]}); + assert!(c.check_command("engine.batch", &steps).is_err()); + // Writes: into a write root only. + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err()); + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project}); + assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath"); + assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok()); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name"); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err()); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..1121c5d91 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() { call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}})); assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B"); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); let (c, err) = call(&mut s, "render_frame", json!({})); assert!(!err, "{c:?}"); @@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() { } } +/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with +/// only `id` and `label`, effects with only `id`, `name` and `category`. +#[test] +fn compact_listings_keep_only_ids_and_names() { + let mut s = server(); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); + let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true})); + assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}"); + // Each compact listing is the full one with the same arguments, entry for entry; `compact` + // wins over `schemas`. + let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| { + let full = call_json(s, tool, args.clone()); + let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect(); + let mut compact = args.clone(); + compact["compact"] = json!(true); + assert_eq!(call_json(s, tool, compact), want, "{tool} {args}"); + }; + for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] { + check(&mut s, "list_commands", args.clone(), &["id", "label"]); + check(&mut s, "command_list", args, &["id", "label"]); + } + check(&mut s, "list_effects", json!({}), &["id", "name", "category"]); + check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]); + // `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it. + let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string(); + for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] { + let full = text(&mut s, tool, json!({})); + assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}"); + let compact = text(&mut s, tool, json!({"compact": true})).len(); + assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len()); + } +} + #[test] fn high_level_tools_add_effect_and_report_state() { let mut s = server(); @@ -1376,7 +1412,103 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); } + +/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file +/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens, +/// when an event reaches one of its controls, or when one of its tasks comes due — and +/// `prefs.set` could grant every script run afterwards the file and network access the user +/// withheld, in the settings file, long after the session. All of that is refused too. +#[test] +fn automation_roots_refuse_running_script_code() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let mut s = server(); + s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap()); + let refused = |s: &mut McpServer, name: &str, args: Value| { + let (c, err) = call(s, name, args.clone()); + assert!(err, "{name} {args} should be refused: {c:?}"); + c[0]["text"].as_str().unwrap_or_default().to_string() + }; + // Every way a script's code starts. + for (command, params) in [ + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ("script.runTasks", json!({})), + ("scriptui.click", json!({"window": 1, "widget": 2})), + ("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})), + ("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})), + ("scriptui.close", json!({"window": 1})), + ] { + let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params})); + assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}"); + } + // Including as a batch step. + let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]})); + assert!(msg.contains("not available while file access is confined"), "{msg}"); + // What scripts may do stays as the user left it, by either spelling of the setting. + for params in [ + json!({"key": "scripting.allowScriptsWriteFiles", "value": true}), + json!({"key": "scripting.allow_scripts_write_files", "value": true}), + json!({"values": {"scripting.warnExecutingFiles": false}}), + json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}), + // `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too… + json!({"key": "scripting/allowScriptsWriteFiles", "value": true}), + // …and the page on its own replaces every setting on it. + json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}), + json!({"values": {"scripting": {"warnExecutingFiles": false}}}), + ] { + let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params})); + assert!(msg.contains("what a script may do"), "{msg}"); + } + let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}})); + assert_eq!(got, json!(false), "{got}"); + // Other settings, and looking at the script windows, still work. + call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}})); + call_json(&mut s, "execute_command", json!({"command": "scriptui.list"})); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn blender_export_requires_an_absolute_path_in_a_write_root() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id())); + let (read, write) = (base.join("read"), base.join("write")); + for dir in [&read, &write] { + std::fs::create_dir_all(dir).unwrap(); + } + let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path})); + assert_eq!(check(&write.join("scene.py")).unwrap(), None); + for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] { + assert!(check(&path).is_err(), "must deny {}", path.display()); + } + for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] { + let error = check(std::path::Path::new(path)).unwrap_err().to_string(); + assert!(error.contains("give an absolute path"), "{path}: {error}"); + } + let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap(); + assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err()); + for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] { + let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]}); + assert!(confinement.check_command("engine.batch", &batch).is_err()); + } + std::fs::remove_dir_all(&base).unwrap(); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a83e0d1ec..1d6074247 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools +/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`. fn list_commands(b: &mut Backend, a: &Value) -> Result { - let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false); + let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true)); let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; - // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut c| { + if compact { + return json!({"id": c["id"], "label": c["label"]}); + } + // Drop empty fields. if let Some(o) = c.as_object_mut() { o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); } @@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("command.describe", json!({"command": id}))?) } +/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`. fn list_effects(b: &mut Backend, a: &Value) -> Result { - json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) + let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?; + if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) { + r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect(); + } + json_reply(r) } fn list_fonts(b: &mut Backend, a: &Value) -> Result { @@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result { static TOOLS: &[ToolDef] = &[ ToolDef { name: "list_commands", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) @@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "list_effects", - description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.", + description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.", bridge_only: false, - schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]), + schema: || { + schema( + json!({ + "filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}, + "compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."} + }), + &[], + ) + }, run: list_effects, }, ToolDef { @@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "command_list", - description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.", bridge_only: false, schema: || { schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, - "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md index c922c95eb..dcc343693 100644 --- a/crates/av1enc/README.md +++ b/crates/av1enc/README.md @@ -48,8 +48,10 @@ reconstruction, so prediction never drifts. reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra, coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts. - **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame - towards the average target (key frames get a lower quantizer and a larger share; the first - key frame is searched in a few passes). + towards the average target (key frames get a lower quantizer and a larger share of the budget of + their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a + few passes, a later key frame that misses its budget by more than 19 % is coded again; the + deviation from the budget is paid back over 16 frames). ## Limits diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index 9bbd56823..d8bc1f825 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,7 +3,7 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane}; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra}; use crate::tables::{ AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, @@ -174,6 +174,8 @@ pub(crate) struct Decider<'a> { ac_q: f32, dc_qi: i64, ac_qi: i64, + /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`. + dead_zone: [[f32; 2]; 2], lambda: f64, lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). @@ -183,6 +185,9 @@ pub(crate) struct Decider<'a> { } const MODE_BITS_INTRA: f64 = 3.0; +/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks. +const INTER_RND: f32 = 0.30; +const INTRA_RND: f32 = 0.38; impl<'a> Decider<'a> { pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self { @@ -209,10 +214,11 @@ impl<'a> Decider<'a> { ac_q: ac_qi as f32, dc_qi, ac_qi, + dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))), lambda, lambda_sad: lambda.sqrt(), half: match refr { - Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)), + Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd), None => Default::default(), }, scratch: None, @@ -577,8 +583,9 @@ impl<'a> Decider<'a> { let (dy, dx) = (m[0] >> 3, m[1] >> 3); dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1 }; + let lg = log2_table(); let mv_bits = |m: [i32; 2]| -> f64 { - let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() }; + let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) }; let cost = @@ -863,12 +870,18 @@ impl<'a> Decider<'a> { } forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half - let rnd = if intra { 0.38f32 } else { 0.30f32 }; + let rnd = if intra { INTRA_RND } else { INTER_RND }; + let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]]; levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { - let q = if k == 0 { self.dc_q } else { self.ac_q }; - let a = (cf.abs() / q + rnd).floor() as i32; + let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) }; + // dead zone: below the exact threshold the level is zero, no division needed + if cf.abs() < thr { + continue; + } + // the operand is >= 0, so truncation equals floor + let a = (cf.abs() / q + rnd) as i32; if a != 0 { *l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) }; nonzero = true; @@ -913,6 +926,33 @@ impl<'a> Decider<'a> { } } +/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is +/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary. +fn zero_threshold(q: f32, rnd: f32) -> f32 { + let (mut lo, mut hi) = (0u32, f32::MAX.to_bits()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if (f32::from_bits(mid) / q + rnd) as i32 >= 1 { + hi = mid; + } else { + lo = mid + 1; + } + } + f32::from_bits(lo) +} + +const LOG2_TABLE_LEN: usize = 1025; + +/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form. +fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] { + static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2())) +} + +fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 { + if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() } +} + /// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place. fn hadamard(v: &mut [i32]) { let n = v.len(); @@ -941,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 { if eob == 0 { return 1.0; } - let mut bits = 3.0 + (eob as f64).log2(); + let lg = log2_table(); + let mut bits = 3.0 + log2_of(lg, eob); for &pos in &scan[..eob] { let a = levels[pos as usize].unsigned_abs(); bits += match a { 0 => 0.7, 1 => 2.5, - _ => 3.5 + 1.6 * (a as f64).log2(), + _ => 3.5 + 1.6 * log2_of(lg, a as usize), }; } bits @@ -961,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] { _ => &DEFAULT_SCAN_32X32, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::{AC_QLOOKUP, DC_QLOOKUP}; + + /// The quantiser as it was before the dead-zone threshold and the truncation. + fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 { + (cf.abs() / q + rnd).floor() as i32 + } + + fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 { + if cf.abs() < thr { + return 0; + } + (cf.abs() / q + rnd) as i32 + } + + #[test] + fn dead_zone_threshold_is_exact() { + let mut state = 0x1234_5678_9abc_def0u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) { + for &qi in table.iter() { + let q = qi as f32; + for rnd in [INTER_RND, INTRA_RND] { + let thr = zero_threshold(q, rnd); + // the boundary itself and its neighbours + let b = thr.to_bits(); + for bits in b.saturating_sub(3)..=b + 3 { + let cf = f32::from_bits(bits); + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}"); + assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr)); + } + for _ in 0..200 { + let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff); + if cf.is_nan() { + continue; + } + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + let cf = (next() % 100_000) as f32 / 100.0; + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + } + } + } + } + } + + #[test] + fn log2_table_matches_direct_log2() { + let t = log2_table(); + for v in 1..3000usize { + assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); + } + } +} diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs index 3fcf446bb..b86db0fa2 100644 --- a/crates/av1enc/src/lib.rs +++ b/crates/av1enc/src/lib.rs @@ -24,6 +24,7 @@ use bitw::{BitWriter, obu}; use decide::{Decider, FrameGeom, RefFrame}; use loopfilter::{LfInfo, loop_filter}; use pred::Plane; +use tables::AC_QLOOKUP; use writer::TileWriter; /// Rate control. @@ -136,10 +137,118 @@ struct RateState { frame_bits: f64, /// Quantizer index for inter frames (continuous). q: f64, + /// Key frame quantizer index minus the inter frame one. + key_off: f64, + /// Quantizer index steps per halving of the frame size, measured from consecutive inter + /// frames (the size/quantizer curve is much steeper than the default on noisy content). + slope: f64, + /// Quantizer index and size (in bits) of the previous inter frame. + prev_inter: Option<(f64, f64)>, /// Accumulated (spent - budget) bits. debt: f64, } +/// Quantizer index steps per halving of the frame size before any measurement. +const RC_SLOPE: f64 = 28.0; +/// Fraction of the quantizer step the size error asks for that is applied per frame. +const RC_GAIN: f64 = 0.8; +/// Frames over which an accumulated deviation from the budget is paid back. +const RC_PAYBACK: f64 = 16.0; + +impl RateState { + /// The nominal bit budget and the starting quantizer index of a key or inter frame. A key + /// frame gets a larger share, and the inter frames of the group get the rest, so that a whole + /// group of `keyint` frames adds up to `keyint * frame_bits`. + fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) { + let keyint = keyint.max(1) as f64; + let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5); + if key { + (self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0)) + } else if keyint > 1.0 { + (self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q) + } else { + (self.frame_bits, self.q) + } + } + + /// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`. + fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) { + let err = (bits / target).log2().clamp(-1.5, 1.5); + if first && key { + // four passes ran: the key frame quantizer is known, the inter one follows from the offset + self.q = (q_used - self.key_off).clamp(1.0, 255.0); + } else if key { + self.key_off = q_used - self.q + RC_GAIN * self.slope * err; + } else { + if let Some((pq, pbits)) = self.prev_inter { + let (dq, dr) = (q_used - pq, (pbits / bits).log2()); + if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 { + self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0); + } + } + self.prev_inter = Some((q_used, bits)); + self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0); + } + } +} + +/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be +/// before it is accepted. +const RC_FIRST_PASSES: usize = 4; +const RC_FIRST_TOLERANCE: f64 = 0.04; +/// Quantizer index steps per halving of the size, for the first correction of the first frame +/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it +/// is, and from there on the passes are placed by `next_probe_q`). +const RC_FIRST_SLOPE: f64 = 30.0; +/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves +/// (19 %) off the target. +const RC_KEY_PASSES: usize = 3; +const RC_KEY_TOLERANCE: f64 = 0.25; +/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame. +const RC_PROBE_SLOPE: f64 = 1.0; +/// Largest change of the quantizer step (octaves) between two passes on one frame. +const RC_PROBE_MAX_STEP: f64 = 2.0; + +/// The quantizer index whose step size is closest to `2^x`. +fn q_for_step(bit_depth: u8, x: f64) -> u8 { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let mut best = 1; + for q in 1..=255usize { + if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() { + best = q; + } + } + best as u8 +} + +/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))` +/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the +/// step only below about 60) in a near log-log line: false position between the closest pair of +/// passes that enclose the target, otherwise a step from the latest one with the slope of the +/// last two. `None` when no untried quantizer lies between the enclosing passes. +fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option { + let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize]; + let x = |q: u8| (table[q as usize] as f64).log2(); + let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0); + let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0); + if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) { + if q_hi - q_lo < 2 { + return None; + } + let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi); + return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1)); + } + let &(q, err) = tried.last()?; + let mut slope = RC_PROBE_SLOPE; + if let [.., (q0, e0), (q1, e1)] = tried + && (x(*q1) - x(*q0)).abs() > 0.05 + && (e0 - e1) / (x(*q1) - x(*q0)) > 0.0 + { + slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0); + } + Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP))) +} + /// AV1 encoder. Frames come out in input order (no hidden/reordered frames). pub struct Encoder { cfg: EncoderConfig, @@ -185,7 +294,7 @@ impl Encoder { // initial guess from the bits per sample let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64); let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0); - Some(RateState { frame_bits, q, debt: 0.0 }) + Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 }) } }; let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc }; @@ -471,38 +580,52 @@ impl Encoder { fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec, RefFrame) { let first = self.frame_num == 0; - let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q); - // key frames get a lower quantizer and a larger share of the budget - let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75; - let budget = if key { frame_bits * key_boost } else { frame_bits }; - // pay back a fraction of the accumulated deviation - let target = (budget - debt * 0.25).max(budget * 0.25); - let mut q = if key { q_inter - 24.0 } else { q_inter }; - let mut best: Option<(Vec, RefFrame, f64)> = None; - let passes = if first { 4 } else { 1 }; - for pass in 0..passes { + let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key); + // pay back the accumulated deviation over the next RC_PAYBACK frames + let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0); + let mut q = q_start; + let mut best: Option<(Vec, RefFrame, f64, f64)> = None; + // The first frame is coded up to four times to find its quantizer (it fixes the + // quantizers of everything after it), a later key frame up to three times (it is a large + // part of the budget of its group). A frame is coded again only while its size is far from + // the target and an untried quantizer is left to try; other frames are coded once. + let (max_passes, tolerance) = if first { + (RC_FIRST_PASSES, RC_FIRST_TOLERANCE) + } else if key { + (RC_KEY_PASSES, RC_KEY_TOLERANCE) + } else { + (1, 0.0) + }; + let mut tried: Vec<(u8, f64)> = Vec::new(); + for _ in 0..max_passes { let qi = q.round().clamp(1.0, 255.0) as u8; + if tried.iter().any(|t| t.0 == qi) { + break; + } let (obu, rf) = self.encode_frame(src, key, qi); let bits = obu.len() as f64 * 8.0; let err = (bits / target).log2(); - let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()); - if better { - best = Some((obu, rf, bits)); + tried.push((qi, err)); + if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) { + best = Some((obu, rf, bits, qi as f64)); } - if pass + 1 < passes { - q = (q + 30.0 * err).clamp(1.0, 255.0); + if err.abs() <= tolerance { + break; + } + let next = if first && tried.len() == 1 { + Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0)) + } else { + next_probe_q(self.cfg.bit_depth, &tried).map(f64::from) + }; + match next { + Some(next) => q = next, + None => break, } } // At least one pass ran, so `best` is set. - let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; + let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) }; rc.debt += bits - budget; - // adapt the inter quantizer: about 30 index steps per doubling of the rate - let err = (bits / target).log2().clamp(-1.0, 1.0); - if first && key { - rc.q = (q + 24.0).clamp(1.0, 255.0); - } else { - rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0); - } + rc.update(key, first, q_used, bits, target); (obu, rf) } diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index 7b354af1f..b899202f4 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -297,7 +297,8 @@ pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) /// The visible `w x h` area of a reference plane interpolated at the sub-sample phase /// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for -/// blocks wider and taller than 4. Used for motion search. +/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses. +#[cfg(test)] pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec { let kh = &SUBPEL_FILTERS[EIGHTTAP][fx]; let kv = &SUBPEL_FILTERS[EIGHTTAP][fy]; @@ -327,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz out } +/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the +/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to +/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )), +/// so the result equals three `subpel_plane` calls bit for bit. +pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec; 3] { + let k = &SUBPEL_FILTERS[EIGHTTAP][8]; + let max = (1i32 << bit_depth) - 1; + let (lx, ly) = (w as i32 - 1, h as i32 - 1); + // horizontal pass at phase 8: Round2( sum, 3 ) + let mut hp = vec![0i32; w * h]; + for y in 0..h { + let row = &refp.row(y)[..w]; + let out = &mut hp[y * w..y * w + w]; + let tap = |x: i32| -> i32 { + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32; + } + round2(s, 3) + }; + if w >= 8 { + for x in 0..3 { + out[x] = tap(x as i32); + } + for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) { + let seg = &row[x - 3..x + 5]; + let mut s = 0i32; + for t in 0..8 { + s += k[t] as i32 * seg[t] as i32; + } + *o = round2(s, 3); + } + for x in w - 4..w { + out[x] = tap(x as i32); + } + } else { + for (x, o) in out.iter_mut().enumerate() { + *o = tap(x as i32); + } + } + } + let p80: Vec = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect(); + let mut p08 = vec![0u16; w * h]; + let mut p88 = vec![0u16; w * h]; + for y in 0..h { + let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize); + let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]); + for x in 0..w { + let (mut a, mut b) = (0i32, 0i32); + for t in 0..8 { + a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32; + b += k[t] as i32 * hp[rows[t] * w + x]; + } + o08[x] = round2(a, 7).clamp(0, max) as u16; + o88[x] = round2(b, 11).clamp(0, max) as u16; + } + } + [p80, p08, p88] +} + #[cfg(test)] mod tests { use super::*; @@ -361,6 +422,20 @@ mod tests { } } + #[test] + fn half_planes_match_subpel_plane() { + for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] { + let mut p = Plane::new(64, 64); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 1024) as u16; + } + let hp = half_planes(&p, w, h, 10); + for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() { + assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})"); + } + } + } + #[test] fn phase_zero_filters_are_impulses() { for f in SUBPEL_FILTERS.iter() { diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 048bfc0db..9d81ead0b 100644 --- a/crates/av1enc/src/transform.rs +++ b/crates/av1enc/src/transform.rs @@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 { cos128(angle - 64) } +#[inline(always)] fn brev(num_bits: u32, x: usize) -> usize { let mut t = 0; for i in 0..num_bits { @@ -63,8 +64,11 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) { t[b] = (x - y).clamp(lo, hi); } -/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place. -fn inverse_dct(t: &mut [i32], n: u32, r: u32) { +/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the +/// bit-reversal permutations and loop bounds fold at compile time. +#[inline(always)] +fn inverse_dct_n(t: &mut [i32], r: u32) { + let n = N; let len = 1usize << n; let mut copy = [0i32; 32]; copy[..len].copy_from_slice(&t[..len]); @@ -76,6 +80,7 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } /// Rotate the high-frequency branches before the four-point core. +#[inline(always)] fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { @@ -112,6 +117,7 @@ fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { } /// Evaluate the four-point core and merge progressively larger branches. +#[inline(always)] fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); @@ -190,6 +196,7 @@ const SINPI_2_9: i64 = 2482; const SINPI_3_9: i64 = 3344; const SINPI_4_9: i64 = 3803; +#[inline(always)] fn inverse_adst4(t: &mut [i32]) { let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64); let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3]; @@ -207,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) { } } -fn adst_in_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_in_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -217,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) { } } -fn adst_out_permute(t: &mut [i32], n: u32) { +#[inline(always)] +fn adst_out_permute(t: &mut [i32]) { + let n = N; let n0 = 1usize << n; let mut copy = [0i32; 16]; copy[..n0].copy_from_slice(&t[..n0]); @@ -231,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) { } } +#[inline(always)] fn inverse_adst8(t: &mut [i32], r: u32) { - adst_in_permute(t, 3); + adst_in_permute::<3>(t); for i in 0..4 { bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true); } @@ -250,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) { for i in 0..2 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 3); + adst_out_permute::<3>(t); } +#[inline(always)] fn inverse_adst16(t: &mut [i32], r: u32) { - adst_in_permute(t, 4); + adst_in_permute::<4>(t); for i in 0..8 { bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true); } @@ -283,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) { for i in 0..4 { bf(t, 2 + 4 * i, 3 + 4 * i, 32, true); } - adst_out_permute(t, 4); + adst_out_permute::<4>(t); } /// 1-D transform kind. @@ -308,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) { } } +#[inline(always)] +fn inverse_dct(t: &mut [i32], n: u32, r: u32) { + match n { + 2 => inverse_dct_n::<2>(t, r), + 3 => inverse_dct_n::<3>(t, r), + 4 => inverse_dct_n::<4>(t, r), + _ => inverse_dct_n::<5>(t, r), + } +} + +#[inline(always)] fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { match kind { Kind::Dct => inverse_dct(t, n, r), @@ -322,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) { /// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised /// coefficients (row stride `n`) into `residual` (row stride `n`). pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { - let log2 = TX_WIDTH_LOG2[tx_sz] as u32; + match TX_WIDTH_LOG2[tx_sz] { + 2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth), + 3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth), + 4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth), + _ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth), + } +} + +/// `inverse_2d` for a block of width `1 << LOG2`. +fn inverse_2d_n(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) { + let log2 = LOG2; let n = 1usize << log2; let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32; let row_clamp = bit_depth + 8; @@ -429,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] { /// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain /// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser). pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { + match n { + 4 => forward_2d_n::<4, 16>(residual, out, tx_type), + 8 => forward_2d_n::<8, 64>(residual, out, tx_type), + 16 => forward_2d_n::<16, 256>(residual, out, tx_type), + _ => forward_2d_dyn(residual, out, n, tx_type), + } +} + +/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing). +#[inline(always)] +fn forward_2d_n(residual: &[i32], out: &mut [f32], tx_type: usize) { + let n = N; + let (ck, rk) = kinds(tx_type); + let bc = &basis(ck, n)[..NN]; + let brt = &basis_t(rk, n)[..NN]; + let residual = &residual[..NN]; + let out = &mut out[..NN]; + let mut tmp = [0f32; NN]; + for i in 0..n { + let t = &mut tmp[i * n..i * n + n]; + for j in 0..n { + let r = residual[i * n + j]; + if r == 0 { + continue; + } + let r = r as f32; + for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) { + *o += r * b; + } + } + } + out.iter_mut().for_each(|v| *v = 0.0); + for k in 0..n { + let o = &mut out[k * n..k * n + n]; + for i in 0..n { + let b = 8.0 * bc[k * n + i]; + for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) { + *v += b * t; + } + } + } +} + +/// 32x32 (and any other size): a const-generic 32 version measured slower. +fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) { let (ck, rk) = kinds(tx_type); let bc = basis(ck, n); let brt = basis_t(rk, n); diff --git a/crates/av1enc/tests/it/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs index 282880e08..edc98c370 100644 --- a/crates/av1enc/tests/it/roundtrip.rs +++ b/crates/av1enc/tests/it/roundtrip.rs @@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() { assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s"); } +/// Over several key frame intervals the average rate stays within 5 % of the target, for a +/// range of targets (the key frame budget is taken out of the inter frames of its group). +#[test] +fn bitrate_mode_is_accurate_over_many_groups() { + let n = 180; + for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] { + let pics: Vec = (0..n).map(|t| synth(192, 128, bd, t)).collect(); + let mut c = cfg(192, 128, bd as u8, 0, keyint); + c.rate = RateControl::Bitrate { kbps }; + let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap(); + let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum(); + let got = bits as f64 / (n as f64 / 30.0) / 1000.0; + eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}"); + assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})"); + } +} + #[test] fn headers() { let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2a0dfc50e..71eb43296 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -97,7 +97,9 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) { return (0.0, 0.0, l); } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let denom = if l > 0.5 { 2.0 - max - min } else { max + min }; + // Over-range (HDR) or negative inputs can make the denominator zero or negative. + let s = if denom > 0.0 { d / denom } else { 1.0 }; let h = if max == r { (((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0) } else if max == g { @@ -262,6 +264,20 @@ impl Label { mod tests { use super::*; + #[test] + fn hsl_round_trip_is_finite_for_out_of_range_colours() { + for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] { + let (h, s, l) = rgb_to_hsl(r, g, b); + assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}"); + let (r2, g2, b2) = hsl_to_rgb(h, s, l); + assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}"); + } + // A 32 bpc colour whose denominator stays positive still round-trips exactly. + let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5); + let (r, g, b) = hsl_to_rgb(h, s, l); + assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}"); + } + #[test] fn hsv_to_rgb_tiny_negative_hue_is_red() { let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0); diff --git a/crates/effects/src/analysis_cache.rs b/crates/effects/src/analysis_cache.rs new file mode 100644 index 000000000..98764d205 --- /dev/null +++ b/crates/effects/src/analysis_cache.rs @@ -0,0 +1,97 @@ +//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated +//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter +//! strings can be cloned, edited in place, or allocated again at an old address. + +use std::sync::{Arc, Mutex, OnceLock}; + +struct Entry { + json: Box, + key: u64, + value: Arc, +} + +pub(crate) struct AnalysisCache { + entries: OnceLock>>>, +} + +pub(crate) fn fnv(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +impl AnalysisCache { + pub(crate) const fn new() -> Self { + Self { entries: OnceLock::new() } + } + + pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option) -> Option<(u64, Arc)> { + let entries = self.entries.get_or_init(Default::default); + { + let m = entries.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + } + let value = Arc::new(parse(json)?); + let key = fnv(json.as_bytes()); + let mut m = entries.lock().unwrap_or_else(|e| e.into_inner()); + // Another render may have filled the entry while this one parsed it. + if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) { + return Some((e.key, e.value.clone())); + } + // Preserve the existing nine-entry bound and clear-on-overflow policy. + if m.len() > 8 { + m.clear(); + } + m.push(Entry { json: json.into(), key, value: value.clone() }); + Some((key, value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn reuses_content_and_invalidates_same_allocation_edits() { + let cache = AnalysisCache::new(); + let calls = Cell::new(0); + let parse = |s: &str| { + calls.set(calls.get() + 1); + Some(s.to_owned()) + }; + let mut json = String::from("analysis-1"); + let (key, a) = cache.get(&json, parse).unwrap(); + let copy = json.clone(); + assert_ne!(json.as_ptr(), copy.as_ptr()); + let (same_key, same) = cache.get(©, parse).unwrap(); + assert_eq!(key, fnv(json.as_bytes())); + assert_eq!(key, same_key); + assert!(Arc::ptr_eq(&a, &same)); + assert_eq!(calls.get(), 1); + let ptr = json.as_ptr(); + json.replace_range(9..10, "2"); + assert_eq!(ptr, json.as_ptr()); + let (changed_key, changed) = cache.get(&json, parse).unwrap(); + assert_ne!(key, changed_key); + assert_eq!(&*changed, "analysis-2"); + assert_eq!(&*a, "analysis-1"); + assert_eq!(calls.get(), 2); + assert!(cache.get("invalid", |_| None).is_none()); + assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed)); + } + + #[test] + fn source_text_retention_is_bounded() { + let cache = AnalysisCache::new(); + for i in 0..30 { + cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap(); + assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9); + } + } +} diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs index ba7878abf..c58df3593 100644 --- a/crates/effects/src/camera_tracker.rs +++ b/crates/effects/src/camera_tracker.rs @@ -16,8 +16,9 @@ //! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create //! commands live in the engine and the UI. -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; + +use crate::analysis_cache::AnalysisCache; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; @@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String { format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted) } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - -type Cache = Mutex>>; - -fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> { - let key = fnv(json.as_bytes()); - let c = c.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(a.clone()); - } - let a = Arc::new(parse(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some(a) -} - /// The stored feature tracks of an instance (`None` when not analysed). pub fn tracks(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, TRACKS); if j.is_empty() { return None; } - cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// The stored camera solve of an instance (`None` when not solved). pub fn solve(params: &Params) -> Option> { - static C: OnceLock> = OnceLock::new(); + static C: AnalysisCache = AnalysisCache::new(); let j = s(params, SOLVE); if j.is_empty() { return None; } - cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty()) + C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty()) } /// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the @@ -338,6 +313,27 @@ mod tests { assert!(!st.lens_distortion && !solve_key(&st).contains("lens")); } + #[test] + fn tracks_and_solve_follow_analysis_replacements() { + let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() }; + let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() }; + let mut ps = params_with(&sol, false); + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + t.start = 2.0; + sol.start = 2.0; + ps.values.insert(TRACKS.into(), Value::Str(t.to_json())); + ps.values.insert(SOLVE.into(), Value::Str(sol.to_json())); + assert_eq!(*tracks(&ps).unwrap(), t); + assert_eq!(*solve(&ps).unwrap(), sol); + assert_eq!(*tracks(&ps.clone()).unwrap(), t); + ps.values.insert(TRACKS.into(), Value::Str("invalid".into())); + ps.values.insert(SOLVE.into(), Value::Str(String::new())); + assert!(tracks(&ps).is_none()); + assert!(solve(&ps).is_none()); + } + #[test] fn undistort_footage_remaps_through_the_solved_lens() { use effectcraft_track::camtrack::Distortion; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 80a937b22..85be739ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,6 +182,20 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + /// The README quotes the number of effects (#2565): every count it gives is the registry's. + #[test] + fn readme_effect_counts_are_current() { + let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default(); + let words: Vec<&str> = readme.split_whitespace().collect(); + let counts: Vec<&str> = words + .windows(3) + .filter(|w| w[0].parse::().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects")))) + .map(|w| w[0]) + .collect(); + let n = super::registry().len().to_string(); + assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}"); + } + #[test] fn cpu_only_controls_keep_effects_off_the_gpu() { use effectcraft_keyframe::Value; diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 08241107c..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,6 +700,7 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 4fc84440e..e69c2a6aa 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() { let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); } + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env); + assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..7add5fc58 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -19,7 +19,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; -use crate::generate::value_noise; +use crate::generate::value_noise_linear_z; use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi { // ---------------------------------------------------------------- Turbulent Displace +/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of +/// plain value noise a few degrees of Evolution barely moved the pattern (#943). fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 { let n = octaves.ceil().max(1.0) as usize; let frac = (octaves - octaves.floor()) as f32; let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32); for o in 0..n { let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 }; - sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; + sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; norm += amp * w; amp *= falloff; f *= 2.0; @@ -93,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); @@ -1115,6 +1120,45 @@ mod tests { assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked"); } + /// #943: every non-equivalent Evolution value renders a visibly different frame. The eased + /// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other. + #[test] + fn turbulent_displace_evolution_changes_the_output() { + // A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and + // the difference is purely how far the pattern moved. + let mut ramp = Image::new(64, 64); + for y in 0..64u32 { + for x in 0..64u32 { + ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]); + } + } + let render = |evo: f64, extra: &[(&str, Value)]| { + let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))]; + run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img + }; + let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5]; + let frames: Vec = evolutions.iter().map(|&e| render(e, &[])).collect(); + for (i, a) in frames.iter().enumerate() { + for (j, b) in frames.iter().enumerate().skip(i + 1) { + let d = max_diff(a, b); + assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]); + } + } + // Constant rate: 20 degrees next to a whole revolution (from either side) is a visible + // step, not the rounding-error size the eased axis gave (under 0.001). + assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02); + assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02); + // 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with + // Cycle (in Revolutions) = 1, at any starting angle. + assert!(max_diff(&frames[0], &frames[4]) > 0.01); + let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))]; + assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4); + assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4); + assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01); + // Random Seed still gives a different pattern at the same Evolution. + assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01); + } + #[test] fn magnify_blending_none_and_scaling() { let img = test_img(32, 32); diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 637cc428e..ae0d61861 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -292,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 { t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } -/// 3D value noise in 0..1 (z = evolution). +/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z +/// eases in and out of every integer. pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, true) +} + +/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic +/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible +/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate. +pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 { + value_noise_z(x, y, z, seed, false) +} + +fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 { let (x0, y0, z0) = (x.floor(), y.floor(), z.floor()); - let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0)); + let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 }); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed); @@ -414,6 +426,27 @@ mod generator_tests { Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0]) } + /// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the + /// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it). + #[test] + fn linear_z_value_noise_moves_at_a_constant_rate() { + for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] { + let a = value_noise_linear_z(x, y, 0.0, seed); + let b = value_noise_linear_z(x, y, 1.0, seed); + for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] { + let want = a + (b - a) * z; + let got = value_noise_linear_z(x, y, z, seed); + assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}"); + } + // Same lattice values at whole z, and x / y are untouched. + assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed)); + } + // The eased version still has the old shape: at z = 0.25 it lags the linear one. + let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5)); + let eased = value_noise(0.3, 0.7, 0.25, 5); + assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3); + } + #[test] fn gradient_ramp_stays_inside_the_layer_alpha() { // #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 204e651ec..c865d4f96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod analysis_cache; pub mod audio_fx; mod blur2; mod blur3; @@ -116,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; -pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, @@ -311,17 +312,47 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } - /// Grow by `pad` transparent pixels on every side. + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + self.pad_sides([left, top, 0, 0]); + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } + /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as + /// clamped by [`Image::effective_pad`]. pub fn pad(&mut self, pad: u32) { - if pad == 0 { + let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad)); + if eff == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; } } +/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within +/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer +/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path. +pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { + const MIN_BUDGET: f64 = 16_777_216.0; + let (w, h) = (f64::from(w), f64::from(h)); + let budget = MIN_BUDGET.max(4.0 * w * h); + if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget { + return pad; + } + // (w + 2p)(h + 2p) = budget, solved for p. + let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + (p.floor().max(0.0) as u32).min(pad) +} + /// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: /// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its /// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp @@ -359,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 Some((dx as i64, dy as i64)) } +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -445,6 +503,18 @@ pub trait EffectHost: Sync { fn params_at(&self, _layer_time: f64) -> Option { None } + /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in + /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer + /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373). + /// `None` when unavailable. + fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { + None + } + /// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no + /// expression), so sampling it over time can be skipped. `false` when unknown. + fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool { + false + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { @@ -525,6 +595,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1300,6 +1379,13 @@ pub fn is_time_dependent(id: &str) -> bool { id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } +/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample +/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an +/// expression returned then. +pub fn reads_param_history(id: &str) -> bool { + matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury") +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) @@ -1324,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en mod tests { use super::*; + /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; + /// big layers may still grow to four times their area, and small pads are untouched. + #[test] + fn padded_buffers_stay_within_the_budget() { + let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p)); + let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8); + assert_eq!(far, 4096, "the effect asks for its full pad"); + let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 }; + b.pad(far); + assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height); + assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place"); + let p = pad_within_budget(3840, 2160, 4096); + assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160); + assert_eq!(pad_within_budget(640, 360, 200), 200); + assert_eq!(pad_within_budget(20, 20, 0), 0); + } + #[test] fn run_fx_passthrough() { let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); @@ -1331,6 +1434,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { @@ -1515,4 +1632,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index b5976b761..35d0960e0 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay *g != before } +/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now: +/// `id` itself when the spec still has it, the new id of a renamed parameter +/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a +/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches. +pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> { + if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) { + return Some(ps.id); + } + if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) { + return Some(new); + } + if id.contains('/') { + return None; + } + spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id) +} + /// Bring an effect instance saved before project schema 2 into effect space (#227). /// /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on @@ -559,6 +576,21 @@ mod tests { assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + /// A saved parameter id maps to the parameter `upgrade_instance` moves it to. + #[test] + fn current_param_id_follows_renames_and_twirl_down_moves() { + let stab = find(crate::warp_stab::ID).unwrap(); + assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness")); + assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness")); + let ctc = find("ec.color.changetocolor").unwrap(); + assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue")); + // Radio Waves' old Velocity is the growth speed, not the new Velocity. + let waves = find("ec.generate.radiowaves").unwrap(); + assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion")); + assert_eq!(current_param_id(stab, "noSuchParam"), None); + assert_eq!(current_param_id(stab, "other/smoothness"), None); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index e1f41954c..15d5d0aac 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let (sx, sy) = (s * kx, s * ky); // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). @@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { let reach = if frac > 0.0 { r + 1 } else { r }; b.pad((reach * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let edge = repeat || ctx.adjustment; let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index d39fd6528..9d21e77a7 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); - let mut cands: Vec<(f32, usize, usize)> = Vec::new(); - for y in (0..=h - size).step_by(step) { - for x in (0..=w - size).step_by(step) { - // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); - for yy in y..y + size { - for xx in x..x + size { - let v = low.data[yy * w + xx]; - s += v; - sq += v * v; - n += 1.0; - opaque &= img.data[yy * w + xx][3] > 0.5; + // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the + // result does not depend on the collection order. + let rows: Vec = (0..=h - size).step_by(step).collect(); + let mut cands: Vec<(f32, usize, usize)> = rows + .par_iter() + .flat_map_iter(|&y| { + let data = low.data.as_slice(); + let alpha = img.data.as_slice(); + (0..=w - size).step_by(step).filter_map(move |x| { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= alpha[yy * w + xx][3] > 0.5; + } } - } - if opaque { - cands.push((sq / n - (s / n).powi(2), x, y)); - } - } - } + opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + }) + }) + .collect(); cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() } @@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u /// The grain level (0.5..2) measured in the sample boxes of `img`. fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { - let mut m = vec![false; img.data.len()]; - let w = img.width as usize; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let st = crate::noise2::grain_stats_boxes(img, boxes); let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 4c22f6d8f..48d840416 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -75,6 +75,102 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each +/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like +/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match +/// the masked version. +pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats { + if boxes.is_empty() { + return grain_stats_masked(img, None); + } + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the + // residual exact inside a box; one more column covers the right neighbour used by the lag-1 + // correlation. + const HALO: usize = 4; + let mut mask = vec![false; w * h]; + for &(x, y, s) in boxes { + for yy in y..y.saturating_add(s).min(h) { + for xx in x..x.saturating_add(s).min(w) { + mask[yy * w + xx] = true; + } + } + } + let mut counted = vec![false; w * h]; + let mut n = [0.0f64; 3]; + let mut sum = [0.0f64; 3]; + let mut sq = [0.0f64; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + let mut crop: Vec<([f32; 3], f32)> = Vec::new(); + let mut planes: Vec = Vec::new(); + for &(bx, by, bs) in boxes { + let x0 = bx.saturating_sub(HALO); + let y0 = by.saturating_sub(HALO); + let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w); + let y1 = by.saturating_add(bs).saturating_add(HALO).min(h); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (cw, ch) = (x1 - x0, y1 - y0); + crop.clear(); + crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x])))); + planes.clear(); + for k in 0..3 { + let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() }; + planes.push(gauss_plane(&pl, 1.5, 1.5)); + } + for ly in 0..bs { + let y = by + ly; + if y >= h { + break; + } + for lx in 0..bs { + let x = bx + lx; + if x >= w { + break; + } + let g = y * w + x; + if !mask[g] || counted[g] { + continue; + } + counted[g] = true; + let l = (y - y0) * cw + (x - x0); + let (_, a) = crop[l]; + if a <= 0.5 { + continue; + } + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + sum[k] += hp as f64; + sq[k] += (hp as f64) * (hp as f64); + n[k] += 1.0; + } + if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 { + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + let hn = crop[l + 1].0[k] - planes[k].data[l + 1]; + rho_num += (hp * hn) as f64; + rho_den += (hp * hp) as f64; + } + } + } + } + } + let mut std = [0.0f32; 3]; + for k in 0..3 { + if n[k] > 1.0 { + let mean = sum[k] / n[k]; + std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32; + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + /// Match Grain's Viewing Mode options. const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; @@ -86,34 +182,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); let src_boxes = sampling_boxes(ctx, &source, b.scale); - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); if view == 1 || view == 2 { // Noise Samples (on the source) / Compensation Samples (on this layer). - let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) }; draw_boxes(&mut img, &boxes, box_col); b.img = img; return b; } - let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { - if boxes.is_empty() { - return None; - } - let w = img.width as usize; - let mut m = vec![false; img.data.len()]; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - Some(m) - }; - let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); + let src_stats = grain_stats_boxes(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; - let orig = b.img.clone(); + // The own-layer boxes only matter for the compensation term; skip them when it is off. + let own = if comp > 0.0 { + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + grain_stats_boxes(&b.img, &own_boxes) + } else { + GrainStats::default() + }; + let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -147,8 +233,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); - if view == 0 { - crate::noise::preview_compose(ctx, &mut b, &orig); + if let Some(orig) = &orig { + crate::noise::preview_compose(ctx, &mut b, orig); } b } @@ -306,6 +392,63 @@ mod tests { img } + #[test] + fn boxed_grain_stats_match_the_masked_ones() { + // Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and + // bottom-right corner cases (clamped crops). + let mut img = noisy(64, 48, 0.2, 9); + for y in 0..img.height { + for x in 0..img.width { + if (x / 7 + y / 5) % 6 == 0 { + let mut p = img.get(x as i64, y as i64); + p[3] = 0.0; + img.set(x, y, p); + } + } + } + let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)]; + let w = img.width as usize; + let mut mask = vec![false; img.data.len()]; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + mask[yy * w + xx] = true; + } + } + } + let masked = grain_stats_masked(&img, Some(&mask)); + let boxed = grain_stats_boxes(&img, &boxes); + for k in 0..3 { + assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}"); + } + assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}"); + // A denser grid where every box overlaps its neighbours (mask dedup across boxes). + let img2 = noisy(96, 64, 0.15, 21); + let mut boxes2 = Vec::new(); + for gy in 0..5 { + for gx in 0..6 { + boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12)); + } + } + let w2 = img2.width as usize; + let mut mask2 = vec![false; img2.data.len()]; + for &(x, y, s) in &boxes2 { + for yy in y..y + s { + for xx in x..x + s { + mask2[yy * w2 + xx] = true; + } + } + } + let masked2 = grain_stats_masked(&img2, Some(&mask2)); + let boxed2 = grain_stats_boxes(&img2, &boxes2); + for k in 0..3 { + assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}"); + } + assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}"); + // No boxes measures the whole image, like no mask. + assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img)); + } + #[test] fn match_grain_transfers_grain_strength() { let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index e61357c9f..6804ea2c7 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool /// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate, /// the mode and the model. The prompt is in each frame's key. pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 { + seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325; fnv(&mut h, ID.as_bytes()); fnv(&mut h, s(params, INPUT_KEY).as_bytes()); @@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn fnv(&mut h, &v.to_bits().to_le_bytes()); } fnv(&mut h, &[tracks(params) as u8]); - fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + fnv(&mut h, model.as_bytes()); h } @@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64 /// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, /// model), the prompt, the tracked span and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { - let mut h = seed(params, layer_size, fps, model); + frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { + let mut h = seed_of(params, layer_size, fps, model); let base = reference_frame(params); - if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) { fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); } for v in [base, reach(params)[0], reach(params)[1]] { @@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool { } /// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still -/// match ([`frozen_key`]) and `f` is one of them. +/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model` +/// or under the model that froze them (which this session may not have loaded, e.g. a fresh +/// headless render): frozen mattes need no segmentation. pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { let j = s(params, FROZEN); if j.is_empty() { @@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: } let fc = frozen_cache(j)?; if fc.key != frozen_key(params, layer_size, fps, model) { - return None; + let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id)); + if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) { + return None; + } } let fr = fc.frames.get(&f)?; let [fw, fh] = fc.size; @@ -705,4 +720,28 @@ mod tests { p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } + + /// Mattes frozen with a model stay usable in a session where that model is not loaded (a + /// fresh headless render): they are used as stored, not re-segmented with another model. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut p = params(&[ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([10.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 70.0])), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into())); + let stub = Stub::default(); + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some()); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + } } diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs index aabbcdce8..5716a2dcb 100644 --- a/crates/effects/src/psim.rs +++ b/crates/effects/src/psim.rs @@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync { /// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the /// CPU drops births at its live-particle cap, which an independent simulation can't know). pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { - let per = rate / SPS; - if per * steps as f64 > cap as f64 + 2.0 { + if rate / SPS * steps as f64 > cap as f64 + 2.0 { return None; } + births_at(steps, cap, |_| rate) +} + +/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a +/// keyframed Birth Rate, #1373). +pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option> { let mut out = Vec::new(); let mut carry = 0.0f64; for i in 0..steps { - carry += per; + carry += rate(i) / SPS; let n = carry.floor() as u32; carry -= n as f64; - for _ in 0..n { - out.push(i as u32); + if n as usize > cap.saturating_sub(out.len()) { + return None; } + out.extend(std::iter::repeat_n(i as u32, n as usize)); } - (out.len() <= cap).then_some(out) + Some(out) } /// Steps per second of the stepped simulations. diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 3a0743af2..333549e7d 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul}; +use crate::util::{SimCache, hash1, params_key_except, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; mod common; @@ -28,15 +28,17 @@ mod weather; pub(crate) use common::{h, hs, pct, pt, spec}; pub use engine::particle_state; #[cfg(test)] -pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; +use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates}; #[cfg(test)] -use engine::{PW_EFFECT_CAMERA, sim_key}; +pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate}; pub(crate) use engine::{SPS, steps_at}; pub use particle_fx::{Blob, mercury_blobs}; +#[cfg(test)] +use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; pub use registry::{specs, sprite_plan}; pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; pub(crate) use sprites::{combine, raster, splat}; -pub use weather::{Bubble, bubble_list, drizzle_drops}; +pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops}; #[cfg(test)] mod tests; diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index b379636ce..beda8c318 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -1,5 +1,5 @@ use super::common::{h, hs}; -use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys}; +use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, ps2_phys, pw_phys}; use super::sprites::{Shape, Sprite}; use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise}; @@ -29,6 +29,14 @@ pub(crate) struct PState { pub parts: Vec, pub carry: f64, pub next_id: u32, + /// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed + /// from with the same history. + pub hist: u64, +} + +/// `hist` after one more step at `rate` particles per second. +fn rates_hash(hist: u64, rate: f64) -> u64 { + (hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5) } /// Physics configuration (world units; y down). @@ -106,7 +114,15 @@ impl Phys { Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id } } + /// One step at the constant [`Phys::rate`] (the tests' reference). + #[cfg(test)] pub(crate) fn step(&self, st: &mut PState, step: u64) { + self.step_at_rate(st, step, self.rate); + } + + /// One step with `rate` particles per second born during it (rather than [`Phys::rate`]): + /// the rate only decides the births, particles already alive move and age as ever. + pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) { let dt = (1.0 / SPS) as f32; let t = step as f32 * dt; let mut drag = self.resistance; @@ -164,7 +180,8 @@ impl Phys { q.age += dt; }); st.parts.retain(|q| q.age < q.life); - st.carry += self.rate / SPS; + st.hist = rates_hash(st.hist, rate); + st.carry += rate / SPS; let n = st.carry.floor() as u32; st.carry -= n as f64; for _ in 0..n { @@ -211,42 +228,111 @@ impl Phys { } /// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or -/// `None` (simulate on the CPU). -pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { +/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time +/// (else [`Phys::rate`]). +pub(super) fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option> { let backend = ctx.env.host?.particles()?; let steps = steps_at(ctx.time); - let births = crate::psim::births(phys.rate, steps, phys.max)?; + let births = match rates { + Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?, + None => crate::psim::births(phys.rate, steps, phys.max)?, + }; let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; let parts = backend.simulate(&req)?; Some(std::sync::Arc::new(PState { parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), carry: 0.0, next_id: births.len() as u32, + hist: 0, })) } +/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter +/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only +/// decides the births of its own time, #1373). Without a host that evaluates the effect at +/// other times, the current value for every step. +pub(super) fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec { + let now = ctx.params.f(id); + // A rate without keyframes or an expression is the same at every step. + let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id)); + (0..steps_at(ctx.time)) + .map(|i| { + let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64()); + if v.is_finite() { v.max(0.0) * k } else { 0.0 } + }) + .collect() +} + +/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with +/// `cache`, resuming only from checkpoints reached with the same rates. +pub(super) fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc { + // `hist` of the state after each number of steps. + let mut prefix = Vec::with_capacity(rates.len() + 1); + prefix.push(0u64); + for r in rates { + prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r)); + } + let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0); + cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist)) +} + +/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own +/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every +/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under +/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it. +pub(super) struct Stepped<'a> { + pub(super) cache: &'a SimCache, + pub(super) salt: u64, + pub(super) skip: &'a [&'a str], + pub(super) k: f64, + pub(super) gpu: bool, +} + +impl Stepped<'_> { + pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + let rates = rate_history(ctx, "birthRate", self.k); + let mut skip = self.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip); + let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None }; + gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates)) + } +} + +/// CC Particle World. +const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true }; +/// CC Particle Systems II. +pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true }; +/// CC Mr. Mercury (its blobs are simulated on the CPU). +pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false }; + +/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time. +pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + PW.state(ctx, phys) +} + /// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, /// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has /// one (GPU particles), else from the CPU simulation. For comparing the two. pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { - let (phys, cache, salt) = match id { - "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), - "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + let st = match id { + "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)), + "ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)), _ => return None, }; - let key = sim_key(ctx, salt); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) } -/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's -/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart -/// the simulation). +/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests' +/// reference): every parameter but CC Particle World's Effect Camera. +#[cfg(test)] pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 { params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA]) } -/// Run a particle system to layer time `t` through `cache`. +/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the +/// tests' reference). +#[cfg(test)] pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) } diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index 6b6c27b01..440951c83 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -1,16 +1,19 @@ -use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite}; +use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite}; use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; -use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul}; +use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul}; // ------------------------------------------------------------------ CC Particle World pub(super) static PW_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle World's Birth Rate. +pub(super) const PW_RATE: f64 = 120.0; + /// CC Particle World's physics. pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; Phys { - rate: pr.f("birthRate").max(0.0) * 120.0, + rate: pr.f("birthRate").max(0.0) * PW_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32], @@ -152,8 +155,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option, mode: u32) -> SpritePlan { pub(super) static PS2_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle Systems II's Birth Rate. +pub(super) const PS2_RATE: f64 = 60.0; + /// CC Particle Systems II's physics. pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; @@ -198,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys { let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); Phys { - rate: pr.f("birthRate").max(0.0) * 60.0, + rate: pr.f("birthRate").max(0.0) * PS2_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0], @@ -225,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); - let phys = ps2_phys(ctx); - let key = sim_key(ctx, 2); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); + let st = PS2.state(ctx, &ps2_phys(ctx)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -250,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { // ------------------------------------------------------------------ CC Mr. Mercury -static MERCURY_CACHE: SimCache = SimCache::new(6); +pub(super) static MERCURY_CACHE: SimCache = SimCache::new(6); + +/// Blobs per second per unit of CC Mr. Mercury's Birth Rate. +pub(super) const MERCURY_RATE: f64 = 10.0; /// A CC Mr. Mercury blob (buffer px). #[derive(Clone, Copy, Debug)] @@ -260,15 +266,14 @@ pub struct Blob { pub r: f32, } -/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). -pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { +/// CC Mr. Mercury's physics. +pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let lh = ctx.layer_size[1] as f32; - let unit = lh.max(1.0); + let unit = (ctx.layer_size[1] as f32).max(1.0); let pos = pr.v2("producer"); let dir = (pr.f("direction") as f32).to_radians(); - let phys = Phys { - rate: pr.f("birthRate").max(0.0) * 10.0, + Phys { + rate: pr.f("birthRate").max(0.0) * MERCURY_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0], @@ -282,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { seed: ctx.seed.wrapping_mul(0xc2b2ae35), max: 4_000, flat: true, - }; - let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); + } +} + +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { + let pr = ctx.params; + let unit = (ctx.layer_size[1] as f32).max(1.0); + let phys = mercury_phys(ctx); + let st = MERCURY.state(ctx, &phys); let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); let s = b.scale as f32; st.parts diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index c6f83ab4e..06ce973b2 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -97,6 +97,60 @@ fn bubbles_rise() { assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); } +/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands +/// in for transparent ones. +#[test] +fn bubbles_near_edges_show_the_layer() { + let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]); + let pale = [0, 1].map(|reflection| { + let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))]; + let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + // Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha. + a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count() + }); + assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); +} + +/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels +/// (a half-resolution preview's layout). +fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image { + let size = 40.0; + let s = crate::find("ec.sim.ccbubbles").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() }; + for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] { + params.values.insert(k.into(), v); + } + let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() }; + let mut b = Buf { img, offset: [0.0; 2], scale }; + b.pad(6); + crate::apply(s, &ctx, b).img +} + +/// #2024: the same when the buffer has transparent padding around the layer: samples past the +/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is +/// still refracted as the pale colour. +#[test] +fn bubbles_near_padded_edges_show_the_layer() { + let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]); + let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count(); + for (scale, side) in [(1.0, 40), (0.5, 20)] { + let counts = [0, 1].map(|reflection| { + let a = padded_bubbles(blue(side), scale, reflection); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + pale(&a) + }); + assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)"); + } + let mut holed = blue(40); + for y in 12..28 { + for x in 12..28 { + holed.set(x, y, [0.0; 4]); + } + } + assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour"); +} + #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); @@ -405,3 +459,266 @@ fn half_resolution_scales_sprites() { let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); } + +// ---------------------------------------------------------- Birth Rate over time (#1373) + +/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`. +struct Rate { + index: usize, + rate: fn(f64) -> f64, +} +impl crate::EffectHost for Rate { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option { + (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t))) + } +} + +/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst). +fn burst(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// The same burst, cut at 0.25 s (an earlier key moved). +fn short_burst(t: f64) -> f64 { + if t < 0.25 { 3.0 } else { 0.0 } +} + +/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s. +fn falling(t: f64) -> f64 { + (3.0 * (1.0 - t / 0.5)).max(0.0) +} + +/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the +/// rendered time), Longevity 1 s. +fn pw_params(seed: f64, now: f64) -> crate::Params { + let spec = crate::find("ec.sim.ccparticleworld").unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] { + p.values.insert(k.to_string(), v); + } + p +} + +fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env } +} + +/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history. +fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec { + let p = pw_params(seed, now); + particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec { + let p = pw_params(seed, now); + let ctx = pw_ctx(&p, t, Some(host), host.index); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); + let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE)); + st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect() +} + +/// Particles born in the first `t` seconds of `rate`. +fn born_by(rate: fn(f64) -> f64, t: f64) -> usize { + crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len() +} + +/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the +/// particles born in it live on (moving and ageing) until their Longevity runs out, rather +/// than vanishing because the rate is now 0. +#[test] +fn a_burst_lives_on_after_birth_rate_drops_to_zero() { + let host = Rate { index: 2, rate: burst }; + // During the burst: as a constant Birth Rate of 3. + assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None)); + // After it: the burst's particles, none newer. + let late = pw_at(101.0, 0.0, 0.9, Some(&host)); + let n = born_by(burst, 0.5); + assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len()); + assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any"); + // They keep moving. + let early = pw_at(101.0, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.id == late[0].id).unwrap(); + assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]); + // Longevity 1 s (± 20 %): all gone by 1.8 s. + assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty()); + // The effect asks for its own index: another effect's history isn't its own. + let p = pw_params(101.0, 0.0); + assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty()); +} + +/// A Birth Rate ramping down isn't simulated as if its current value had always applied. +#[test] +fn a_ramped_birth_rate_bears_what_each_moment_asked_for() { + let host = Rate { index: 0, rate: falling }; + let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host)); + let constant = pw_at(102.0, falling(0.25), 0.25, None); + assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive"); + assert_eq!(constant.len(), born_by(|_| 1.5, 0.25)); + assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len()); +} + +/// Seeking backward, forward and to the same time again gives the state simulated from 0. +#[test] +fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() { + let host = Rate { index: 0, rate: burst }; + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}"); + } +} + +/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints), +/// but a checkpoint is only resumed with the history that reached it: after an earlier key +/// moves, the same current rate (0) gives the new history's particles. +#[test] +fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + let a = pw_at(104.0, 0.0, 0.9, Some(&long)); + let b = pw_at(104.0, 0.0, 0.9, Some(&short)); + assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short)); + assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len()); + assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long)); +} + +/// A constant Birth Rate gives exactly the particles it did before the history was sampled. +#[test] +fn a_constant_birth_rate_is_unchanged() { + let host = Rate { index: 0, rate: |_| 2.0 }; + let p = pw_params(105.0, 2.0); + let ctx = pw_ctx(&p, 1.3, None, 0); + let phys = pw_phys(&ctx); + let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys); + let now = pw_at(105.0, 2.0, 1.3, Some(&host)); + assert_eq!(now.len(), before.parts.len()); + assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age)); + assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000)); +} + +/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the +/// CPU simulation numbers. +#[test] +fn the_birth_schedule_follows_the_rate_history() { + let host = Rate { index: 0, rate: falling }; + let p = pw_params(106.0, 0.0); + let ctx = pw_ctx(&p, 0.7, Some(&host), 0); + let rates = rate_history(&ctx, "birthRate", PW_RATE); + let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates); + let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap(); + assert_eq!(st.next_id as usize, births.len()); + assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); +} + +// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time + +/// A stepped particle effect: its id, physics and simulation. +struct System { + id: &'static str, + phys: fn(&EffectCtx) -> Phys, + stepped: Stepped<'static>, +} + +const SYSTEMS: [System; 2] = + [System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }]; + +/// `id`'s parameters with Birth Rate `now` and Longevity 1 s. +fn sys_params(id: &str, now: f64) -> crate::Params { + let spec = crate::find(id).unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] { + assert!(p.values.contains_key(k), "{id}: no {k}"); + p.values.insert(k.to_string(), v); + } + p +} + +/// The context at layer time `t` (`seed` keeps each test's simulations apart). +fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env } +} + +/// The live particles (id, position, age) of `sys` at `t` through its simulation. +fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// The same, simulated from layer time 0 with a cache of its own (the reference). +fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, Some(host), seed); + let mut skip = sys.stepped.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip); + let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k)); + st.parts.iter().map(|q| (q.id, q.p, q.age)).collect() +} + +/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs. +fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + match sys.id { + "ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(), + id => particle_state(id, &ctx).unwrap().len(), + } +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate +/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() { + let host = Rate { index: 1, rate: burst }; + for sys in &SYSTEMS { + let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len(); + assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id); + let late = sys_at(sys, 201, 0.0, 0.9, Some(&host)); + assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len()); + assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id); + assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id); + let early = sys_at(sys, 201, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap(); + assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]); + assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id); + } +} + +/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same +/// cache key) isn't served from the old history's checkpoints. +#[test] +fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + for sys in &SYSTEMS { + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id); + } + let b = sys_at(sys, 202, 0.0, 0.9, Some(&short)); + assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id); + assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id); + } +} + +/// A constant Birth Rate gives exactly the particles the constant-rate simulation did. +#[test] +fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() { + let host = Rate { index: 0, rate: |_| 2.0 }; + for sys in &SYSTEMS { + let p = sys_params(sys.id, 2.0); + let ctx = sys_ctx(&p, 1.3, None, 203); + let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx)); + let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect(); + assert!(!before.is_empty()); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id); + } +} diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index b93c0e48b..347ab4939 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,7 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; +use crate::util::layer_rect_at; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall @@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { .collect() } +/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the +/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat +/// the layer's edge pixels instead of reaching transparent padding around it (#2024). +pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] { + let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale); + let (x0, y0) = (lx + 0.5, ly + 0.5); + [x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32] +} + pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let metal = pr.e("reflectionType") == 1; let shading = pr.e("shadingType"); let list = bubble_list(ctx, &b); + let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). let k = if metal { 1.6 } else { -0.7 }; let nz = (1.0 - d2).max(0.0).sqrt(); - let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1)); + let p = src.sample_bilinear_clamped(sx as f64, sy as f64); let (c, sa) = unpremul(p); let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; let mut a = cov; diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 403c325cd..e28f9a4fa 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -351,7 +351,7 @@ fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { let rr = (2.0 / 3.0 * y) / r; // Cube rounding. let (cx, cz) = (q, rr); - let cy = -cx - cz; + let cy = (-3f64.sqrt() / 3.0 * x - y / 3.0) / r; let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); if dx > dy && dx > dz { diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index ae55d095d..9c81dab10 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread: +/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes). +const PAR_MIN: usize = 4096; + /// A single-channel `f32` image (row-major). #[derive(Clone, Debug, Default, PartialEq)] pub struct Plane { @@ -62,6 +66,15 @@ impl Plane { pub fn transpose(&self) -> Plane { let (w, h) = (self.w, self.h); let mut out = Plane::new(h, w); + if w.saturating_mul(h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the copy itself. + for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() { + for y in 0..h { + col[y] = self.data[y * w + x]; + } + } + return out; + } out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { for y in 0..h { col[y] = self.data[y * w + x]; @@ -75,6 +88,11 @@ impl Plane { if self.w == 0 { return out; } + if self.w.saturating_mul(self.h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the rows themselves. + out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o)); + return out; + } out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o)); out } @@ -534,14 +552,29 @@ impl SimCache { /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step /// `i` to `i + 1`. - pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + self.run_where(key, target, init, step, |_, _| true) + } + + /// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a + /// parameter's values over time): a checkpoint after `s` steps is only resumed from when + /// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace + /// those at the same step. + pub fn run_where( + &self, + key: u64, + target: u64, + init: impl FnOnce() -> S, + mut step: impl FnMut(&mut S, u64), + valid: impl Fn(u64, &S) -> bool, + ) -> std::sync::Arc { use std::sync::Arc; let start = { let mut g = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; g.iter_mut().find(|e| e.key == key).and_then(|e| { e.used = tick; - e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) }) }; let had_start = start.is_some(); @@ -579,9 +612,8 @@ impl SimCache { let e = &mut g[idx]; e.used = tick; for (s, st) in fresh { - if !e.checkpoints.iter().any(|(c, _)| *c == s) { - e.checkpoints.push((s, st)); - } + e.checkpoints.retain(|(c, _)| *c != s); + e.checkpoints.push((s, st)); } // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. if e.checkpoints.len() > 256 { diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index 226654a04..8d9fe6f0e 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings { } } -fn fnv(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - h ^= *b as u64; - h = h.wrapping_mul(0x0100_0000_01b3); - } - h -} - struct KeyHasher(u64); impl Hasher for KeyHasher { fn finish(&self) -> u64 { @@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 { h.finish() } -type AnalysisCache = Mutex>>; type PlanCache = Mutex>>; -/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate). +/// Parsed analyses and their content keys; cache hits compare source text without rehashing. fn analysis_cached(json: &str) -> Option<(u64, Arc)> { - static C: OnceLock = OnceLock::new(); - let key = fnv(json.as_bytes()); - let c = C.get_or_init(Default::default); - if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some((key, a.clone())); - } - let a = Arc::new(WarpAnalysis::from_json(json)?); - let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if m.len() > 8 { - m.clear(); - } - m.insert(key, a.clone()); - Some((key, a)) + static C: crate::analysis_cache::AnalysisCache = crate::analysis_cache::AnalysisCache::new(); + C.get(json, WarpAnalysis::from_json) } fn plans() -> &'static PlanCache { @@ -223,6 +202,19 @@ fn plan_cached(akey: u64, a: &WarpAnalysis, s: &StabSettings) -> Arc { p } +/// Validate and cache the default stabilization plan after analysis. Use the serialized +/// analysis so the cached plan has exactly the same inputs as the first render, including +/// any floating-point round trip through JSON. Other settings still get their own plan. +pub fn prepare_default_plan(json: &str) -> bool { + let Some((key, a)) = analysis_cached(json) else { return false }; + if a.is_empty() { + return false; + } + let fps = if a.frame_duration > 0.0 { 1.0 / a.frame_duration } else { 30.0 }; + let p = plan_cached(key, &a, &StabSettings { fps, ..Default::default() }); + p.warps.len() == a.frames.len() && p.warps.iter().all(|h| h.0.iter().flatten().all(|v| v.is_finite())) +} + /// The stored analysis of an instance (`None` when not analysed). pub fn analysis(params: &Params) -> Option> { let j = s(params, ANALYSIS); @@ -531,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) { mod tests { use super::*; use crate::EffectEnv; + use crate::analysis_cache::fnv; use effectcraft_track::stabilize::FrameMotion; fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String { @@ -601,6 +594,61 @@ mod tests { assert_eq!(render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }).img.data, img.data); } + #[test] + fn prepared_default_plan_preserves_first_render_and_is_reused() { + let json = analysis_json(25, 64.0, 48.0, |k| [((k * 7) % 5) as f64 - 2.0, k as f64 * 0.1]); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 0.012345678901234567; + a.tracks = (0..24) + .map(|id| effectcraft_track::camtrack::Track2D { + id, + start: 0, + pts: (0..25).map(|k| [8.0 + (id % 6) as f32 * 8.0 + ((k * 7) % 5) as f32, 8.0 + (id / 6) as f32 * 8.0 + k as f32 * 0.1]).collect(), + }) + .collect(); + let json = a.to_json(); + let ps = params(&json, 2, 0); + let ctx = EffectCtx { params: &ps, time: a.start, layer_size: a.size, seed: 1, adjustment: false, env: EffectEnv::default() }; + let input = Buf { img: frame([30.0, 22.0]), offset: [0.0; 2], scale: 1.0 }; + // Original flow: validate, discard the plan, then render from the stored analysis. + let _discarded = plan(&a, &StabSettings { fps: 25.0, ..Default::default() }); + let before = render(&ctx, input.clone()); + let key = (fnv(json.as_bytes()), settings_key(&settings(&ps, 25.0))); + plans().lock().unwrap().remove(&key); + assert!(prepare_default_plan(&json)); + let prepared = plans().lock().unwrap().get(&key).unwrap().clone(); + assert!(!prepared.meshes.is_empty(), "exercise the Subspace Warp plan"); + let after = render(&ctx, input); + let (_, _, rendered) = plan_at(&ps, a.start).unwrap(); + assert!(Arc::ptr_eq(&prepared, &rendered)); + assert_eq!(before.offset, after.offset); + assert_eq!(before.scale.to_bits(), after.scale.to_bits()); + for (a, b) in before.img.data.iter().flatten().zip(after.img.data.iter().flatten()) { + assert_eq!(a.to_bits(), b.to_bits()); + } + assert!(!prepare_default_plan("invalid")); + assert!(!prepare_default_plan(&WarpAnalysis::default().to_json())); + } + + #[test] + fn changing_analysis_changes_the_plan_key() { + let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]); + let mut ps = params(&json, 2, 0); + let before = summary_key(&ps).unwrap(); + assert_eq!(before[0], fnv(json.as_bytes())); + let mut a = WarpAnalysis::from_json(&json).unwrap(); + a.start = 1.0; + ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json())); + let after = summary_key(&ps).unwrap(); + assert_ne!(before[0], after[0]); + assert_eq!(before[1], after[1]); + assert_eq!(*analysis(&ps).unwrap(), a); + ps.values.insert(ANALYSIS.into(), Value::Str(json)); + assert_eq!(summary_key(&ps).unwrap(), before); + ps.values.insert(ANALYSIS.into(), Value::Str(String::new())); + assert!(analysis(&ps).is_none()); + } + #[test] fn nested_param_lookup() { let mut p = Params::default(); diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 2a3a7c724..388790901 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43), ("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22), ("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d), - ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae), - ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed), - ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d), - ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533), - ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2), - ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27), - ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce), - ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c), - ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), - ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), - ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), - ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), - ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067), - ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e), - ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc), - ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479), - ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641), - ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f), - ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284), - ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e), + ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4), + ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3), + ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156), + ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90), + ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a), + ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), + ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), + ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), + ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43), + ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e), + ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2), + ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715), + ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), + ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), + ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), + ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802), + ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d), + ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a), + ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618), + ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe), ("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5), ("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3), ("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557), diff --git a/crates/engine/presets/behaviors.json b/crates/engine/presets/behaviors.json new file mode 100644 index 000000000..71e87db52 --- /dev/null +++ b/crates/engine/presets/behaviors.json @@ -0,0 +1,248 @@ +{ + "format": "effectcraft.animationPresets/1", + "notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.", + "presets": [ + { + "id": "wiggleSoft", + "name": "Soft Wiggle", + "category": "Behaviors", + "description": "The layer wanders gently around its position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]" + } + } + }, + { + "id": "shake", + "name": "Shake", + "category": "Behaviors", + "description": "A fast, tight shake of the layer's position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(14, 12); [w[0], w[1]]" + } + } + }, + { + "id": "floatUpDown", + "name": "Float", + "category": "Behaviors", + "description": "The layer bobs up and down like it floats on water.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]" + } + } + }, + { + "id": "sway", + "name": "Sway", + "category": "Behaviors", + "description": "The layer sways left and right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]" + } + } + }, + { + "id": "orbit", + "name": "Orbit", + "category": "Behaviors", + "description": "The layer travels round a circle around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]" + } + } + }, + { + "id": "figureEight", + "name": "Figure Eight", + "category": "Behaviors", + "description": "The layer traces a figure eight around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]" + } + } + }, + { + "id": "bouncingBall", + "name": "Bouncing Ball", + "category": "Behaviors", + "description": "The layer bounces up from its position again and again.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]" + } + } + }, + { + "id": "driftRight", + "name": "Drift Right", + "category": "Behaviors", + "description": "The layer drifts steadily to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*40), value[1]+(0)]" + } + } + }, + { + "id": "driftDiagonal", + "name": "Drift Diagonal", + "category": "Behaviors", + "description": "The layer drifts slowly down and to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*30), value[1]+(time*18)]" + } + } + }, + { + "id": "pendulum", + "name": "Pendulum", + "category": "Behaviors", + "description": "The layer swings like a pendulum.", + "layer": { + "expr": { + "transform/rotation": "value + Math.sin(time*2.4)*25" + } + } + }, + { + "id": "wobble", + "name": "Wobble", + "category": "Behaviors", + "description": "The layer rotates with an irregular wobble.", + "layer": { + "expr": { + "transform/rotation": "wiggle(2, 10)" + } + } + }, + { + "id": "spinSlow", + "name": "Spin Slow", + "category": "Behaviors", + "description": "The layer rotates once every ten seconds.", + "layer": { + "expr": { + "transform/rotation": "value + time*36" + } + } + }, + { + "id": "spinFast", + "name": "Spin Fast", + "category": "Behaviors", + "description": "The layer rotates twice a second.", + "layer": { + "expr": { + "transform/rotation": "value + time*720" + } + } + }, + { + "id": "pulse", + "name": "Pulse", + "category": "Behaviors", + "description": "The layer swells and shrinks smoothly.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]" + } + } + }, + { + "id": "heartbeat", + "name": "Heartbeat", + "category": "Behaviors", + "description": "Two quick beats, then a rest, repeating every second.", + "layer": { + "expr": { + "transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]" + } + } + }, + { + "id": "breathe", + "name": "Breathe", + "category": "Behaviors", + "description": "The layer slowly grows and settles back.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]" + } + } + }, + { + "id": "rubberBand", + "name": "Rubber Band", + "category": "Behaviors", + "description": "Width and height stretch against each other.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]" + } + } + }, + { + "id": "jitterScale", + "name": "Jitter Scale", + "category": "Behaviors", + "description": "The layer's size jitters randomly.", + "layer": { + "expr": { + "transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]" + } + } + }, + { + "id": "blink", + "name": "Blink", + "category": "Behaviors", + "description": "The layer switches on and off twice a second.", + "layer": { + "expr": { + "transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0" + } + } + }, + { + "id": "strobe", + "name": "Strobe", + "category": "Behaviors", + "description": "The layer flashes on for a few frames each beat.", + "layer": { + "expr": { + "transform/opacity": "((time*6) % 1 < 0.25) ? value : 0" + } + } + }, + { + "id": "flicker", + "name": "Flicker", + "category": "Behaviors", + "description": "The layer's opacity flickers like a failing lamp.", + "layer": { + "expr": { + "transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))" + } + } + }, + { + "id": "fadePulse", + "name": "Fade Pulse", + "category": "Behaviors", + "description": "Opacity breathes between full and a third.", + "layer": { + "expr": { + "transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))" + } + } + } + ] +} diff --git a/crates/engine/presets/behaviors.json.attribution b/crates/engine/presets/behaviors.json.attribution new file mode 100644 index 000000000..7c71c62e8 --- /dev/null +++ b/crates/engine/presets/behaviors.json.attribution @@ -0,0 +1,7 @@ +asset: behaviors.json +title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510) +notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values. diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 434cdd8af..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -207,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 5472d4e8c..71ee45ee6 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -4,7 +4,7 @@ //! the Warp Stabilizer, `Renderer::layer_input`) for every frame between the layer's In and Out //! points on a background thread, and feeds the frames to `effectcraft_track::stabilize`'s //! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the -//! stabilization plan once to validate the result. When the job finishes, the analysis is +//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that //! change those clear the analysis (`invalidate`, run after every edit) and queue the effect @@ -21,7 +21,7 @@ use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSour use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; -use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, StabSettings, WarpAnalysis}; +use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis}; use serde::Serialize; use crate::tracking::lock; @@ -245,9 +245,8 @@ async fn run_work(w: WarpWork, shared: &WarpShared) { let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let a = an.finish(lt0, fd); - // Step 2: derive a plan once (validates the motion and warms nothing else up). - let plan = effectcraft_track::stabilize::plan(&a, &StabSettings { fps: if fd > 0.0 { 1.0 / fd } else { 30.0 }, ..Default::default() }); - if plan.warps.len() != a.frames.len() || plan.warps.iter().any(|h| h.0.iter().flatten().any(|v| !v.is_finite())) { + // Plan from the stored JSON, exactly as rendering does, and retain it for the first render. + if !warp_stab::prepare_default_plan(&a.to_json()) { return fail("the stabilization could not be computed"); } *lock(&shared.result) = Some(a); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..bd089b52b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -10,6 +10,7 @@ mod animation; pub(crate) mod app_more; mod autotrace; mod batch; +mod blender; mod camera_cmds; mod comp; pub(crate) mod comp_more; diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index 325845115..e711392e0 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { clip.push(KeyClip { layer: lid, path, keys }); } let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]); s.state.key_clipboard = clip; s.state.clip_is_keys = true; Ok(json!(n)) diff --git a/crates/engine/src/cmd/anim_presets.rs b/crates/engine/src/cmd/anim_presets.rs index fc3f95143..88022a0e3 100644 --- a/crates/engine/src/cmd/anim_presets.rs +++ b/crates/engine/src/cmd/anim_presets.rs @@ -1,6 +1,7 @@ //! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the //! Transitions category (original presets built on our Transition effects and layer opacity) and -//! the Backgrounds category (generators, noise and colour effects driven by expressions). +//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the +//! Behaviors category (transform and opacity driven by expressions, no effects). //! //! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting //! at the current time, on every selected layer. The library is data @@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json" /// The original background presets (`presets/backgrounds.json`). pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json"); +/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's +/// transform and opacity, no effects. +pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json"); + /// Keys per animated property, so a hostile inline preset can't allocate without bound. const MAX_KEYS: usize = 256; /// Longest expression text an inline preset may carry. const MAX_EXPR: usize = 4096; fn presets() -> Vec { - [TRANSITIONS_JSON, BACKGROUNDS_JSON] + [TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON] .iter() .flat_map(|src| serde_json::from_str::(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) .collect() diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 1bdf3fed7..3f9c195c2 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, use effectcraft_time::Tick; use serde_json::{Value, json}; +use super::effect::{effect_time_error, retime_effect}; 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). pub const PRESET_TAG: &str = "effectcraftAnimationPreset"; +/// The preset format version that `anim.savePreset` writes. +pub const PRESET_VERSION: u64 = 2; +/// The first preset version that stores effect keys relative to the earliest key (#1971). +const RELATIVE_EFFECT_KEYS: u64 = 2; fn has_selected_props(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; @@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> { .collect() } +/// The earliest key anywhere in `groups`, nested controls included. +fn earliest_key<'a>(groups: impl IntoIterator) -> Option { + let mut first: Option = None; + for g in groups { + g.walk("", &mut |_, pr| { + if let Some(t) = pr.keys.iter().map(|k| k.time).min() { + first = Some(first.map_or(t, |f| f.min(t))); + } + }); + } + first +} + // ---------------------------------------------------------------- presets fn save_preset(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string(); let comp = s.active_comp().ok_or(EngineError::NoComp)?; let t = s.time(); - let effects: Vec<(LayerId, PropGroup)> = selected_effects(s); + let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s); let effect_uids: Vec = effects.iter().map(|(_, g)| g.uid).collect(); let mut props = vec![]; for (lid, uid) in selected_leaf_props(s) { @@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { return Err(bad("anim.savePreset", "select properties or effects to save")); } // Key times are stored relative to the earliest key so the preset applies at the CTI. - let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO); + let first = + props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO); + for (_, g) in &mut effects { + retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?; + } let props_json: Vec = props .iter() .map(|(_, mp, pr)| { @@ -82,12 +104,27 @@ fn save_preset(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name") .map(str::to_string) .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()); - let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json}); + let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json}); let text = serde_json::to_string_pretty(&doc).unwrap_or_default(); s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) } +/// Rewrite an effect property path in a preset (`effects/[#n]/`) to the current +/// parameter id when it names a parameter that the effect has since renamed or moved into a +/// twirl-down group. Any other path comes back unchanged. +fn upgrade_effect_path(path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let effect = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() && n.parse::().is_ok() => m, + _ => seg, + }; + match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) { + Some(id) if id != tail => format!("effects/{seg}/{id}"), + _ => path.to_string(), + } +} + fn apply_preset(s: &mut Session, p: &Value) -> Result { let doc: Value = match (p.get("preset"), str_p(p, "path")) { (Some(v), _) => v.clone(), @@ -110,14 +147,22 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { s.save_prefs(); s.prefs_revision += 1; } - let effects: Vec = + let mut effects: Vec = doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); + // Older presets stored effect keys at the source layer's times. + if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS + && let Some(first) = earliest_key(&effects) + { + for g in &mut effects { + retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; + } + } let props: Vec<(String, Property)> = doc["properties"] .as_array() .map(|a| { a.iter() .filter_map(|v| { - let path = v["path"].as_str()?.to_string(); + let path = upgrade_effect_path(v["path"].as_str()?); let value: KV = serde_json::from_value(v["value"].clone()).ok()?; let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); @@ -130,16 +175,30 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { }) .unwrap_or_default(); let t = s.time(); + // Effect bounds per layer size the point defaults of parameters added since the preset was saved. + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let sizes: BTreeMap = + ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect(); let mut applied = 0; let mut skipped = vec![]; s.edit("Apply Animation Preset", None, |proj, _| { let mut next = proj.next_id; for lid in &ids { + let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]); let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(fx) = l.props.sub_mut("effects") { for g in &effects { let mut g = g.clone(); + // Effects saved by an earlier version get the upgrade a project gets on open. + let spec = match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect), + _ => None, + }; + if let Some(spec) = spec { + effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size); + } + retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?; g.reassign_uids(&mut next); next += 1; fx.children.push(Node::Group(g)); diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..29597a3bc 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?; s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) @@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result { let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| is_user(l)) { + for l in c.layers.iter_mut_where(|l| is_user(l)) { l.source = LayerSource::Comp { item: comp }; n += 1; } diff --git a/crates/engine/src/cmd/batch.rs b/crates/engine/src/cmd/batch.rs index f6e6901cc..5931fe09d 100644 --- a/crates/engine/src/cmd/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs new file mode 100644 index 000000000..e6c678101 --- /dev/null +++ b/crates/engine/src/cmd/blender.rs @@ -0,0 +1,295 @@ +//! Camera-solve interchange through Blender's public Python API. The generated script is +//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded. + +use std::path::Path; + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid}; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; +use serde_json::{Value, json}; + +mod validate; + +use super::{b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "camera.exportBlender"; +const MAX_FRAMES: usize = 100_000; +const MAX_POINTS: usize = 100_000; +const MAX_JSON: usize = 64 * 1024 * 1024; +const MAX_FRAME: i64 = 1_048_574; + +fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result { + let v = match p.get(key) { + None => default, + Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?, + }; + if !v.is_finite() || !(min..=max).contains(&v) { + return Err(bad(CMD, format!("{key}: expected {min}..{max}"))); + } + Ok(v) +} + +fn finite(values: impl IntoIterator) -> Result<()> { + if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) { + return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker")); + } + Ok(()) +} + +/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up). +fn position(p: [f64; 3], scale: f64) -> [f64; 3] { + [p[0] * scale, p[2] * scale, -p[1] * scale] +} + +fn media_path(s: &Session, path: &str) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting")); + } + let path = Path::new(path); + if path.is_absolute() || cfg!(target_arch = "wasm32") { + return Ok(path.to_string_lossy().into_owned()); + } + let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?; + let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd); + Ok(base.join(path).to_string_lossy().into_owned()) +} + +pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result { + if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files { + return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on")); + } + let options = Options::parse(p)?; + let Options { path, sensor, units, start, include_points } = options; + if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) { + return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?; + let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?; + for name in [ct::SOLVE, ct::TRACKS] { + if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) { + return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit")); + } + } + let params = crate::camera_track::static_params(g); + let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?; + let (lo, end) = validate::timing(comp, layer, &solve, start)?; + let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?; + validate::geometry(&solve, sensor)?; + let camera = params + .get(ct::CAMERA) + .map(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64) + .and_then(|id| comp.layer(LayerId(id as u64))) + .filter(|l| l.is_camera()); + let warnings = scene_warnings(camera, &solve, footage); + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) }; + let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?; + let mut points = Vec::new(); + for point in &solve.points { + finite(point.pos.into_iter().chain([point.error as f64]))?; + if point.first > point.last || point.last as usize >= solve.frames.len() { + return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker")); + } + let location = position(world.apply(point.pos), units); + finite(location)?; + if include_points { + points.push(json!({"id": point.id, "location": location, "error": point.error, + "first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)})); + } + } + let tracks = ct::tracks(¶ms); + if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) { + return Err(bad(CMD, "2D tracks exceed export limits")); + } + let markers: Vec = comp + .markers + .iter() + .filter_map(|m| { + let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo))); + if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None } + }) + .collect(); + let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name), + "resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den, + "startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units, + "media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"}, + "fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode, + "frameCount": source_count, "loopCount": footage.loop_count}, + "frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(), + "groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase, + "solve": solve, "tracks": if include_points { tracks.as_deref() } else { None }, + "warnings": warnings}); + let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?; + if data.len() > MAX_JSON { + return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot")); + } + let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?; + let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed); + s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?; + Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings})) +} + +struct Options<'a> { + path: &'a str, + sensor: f64, + units: f64, + start: i64, + include_points: bool, +} + +impl<'a> Options<'a> { + fn parse(p: &'a Value) -> Result { + let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?; + if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) { + return Err(bad(CMD, "the export path must end in .py")); + } + let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?; + let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?; + let start = match p.get("startFrame") { + None => 1, + Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?, + }; + let include_points = match p.get("includePoints") { + Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")), + _ => b_p(p, "includePoints").unwrap_or(true), + }; + Ok(Self { path, sensor, units, start, include_points }) + } +} + +struct Source<'a> { + footage: &'a Footage, + path: String, + fps: f64, + count: i64, +} + +fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result> { + let footage = match layer.source { + LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }), + _ => None, + } + .filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still)); + let footage = footage + .ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?; + let source_path = media_path(s, &footage.path)?; + if Path::new(path) == Path::new(&source_path) { + return Err(bad(CMD, "the export path must differ from the source footage")); + } + if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] { + return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video")); + } + let source_fps = footage.frame_rate.as_f64(); + if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 { + return Err(bad(CMD, "invalid source footage frame rate")); + } + // Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count, + // clamp to the total loop range, then wrap. Native FPS does not change the frame index. + let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128); + let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor; + let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count }; + if !(1..=MAX_FRAME as i128).contains(&source_count) { + return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first")); + } + let source_count = source_count as i64; + Ok(Source { footage, path: source_path, fps: source_fps, count: source_count }) +} + +fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec { + let mut warnings = vec![ + "Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(), + "The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(), + ]; + if camera.is_none() { + warnings + .push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into()); + } else { + warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into()); + } + if solve.distortion.is_some_and(|d| !d.is_none()) { + warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into()); + } + if (footage.pixel_aspect - 1.0).abs() > 1e-6 { + warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into()); + } + warnings +} + +/// Bake the solved camera and the linked camera's optical settings at exact composition ticks. +fn bake_frames( + ctx0: &EvalCtx<'_>, + layer: &Layer, + solve: &CameraSolve, + camera: Option<&Layer>, + options: &Options<'_>, + footage: &Footage, + source_count: i64, +) -> Result<(Vec, linalg::Similarity)> { + let comp = ctx0.comp; + let lo = ctx0.time; + let fd = comp.frame_duration(); + let sensor = options.sensor; + let units = options.units; + let start = options.start; + let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?; + let m = ctx0.world_matrix(layer).0; + let layer_scale = m[0][0].hypot(m[1][0]); + finite([layer_scale])?; + if layer_scale <= 1e-9 { + return Err(bad(CMD, "the tracked layer has zero scale")); + } + let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale); + finite([world.s * units])?; + let mut frames = Vec::with_capacity(solve.frames.len()); + for (i, frame) in solve.frames.iter().enumerate() { + let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64))); + let ctx = ctx0.at(time); + let pose = linalg::mm(&world.r, &linalg::mt(&frame.r())); + let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0); + let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0); + let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0); + let location = position(world.apply(frame.center), units); + let lens = frame.focal * sensor / solve.size[0]; + let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g))); + let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback); + let focus = value("focusDistance", world.s) * units; + let aperture = value("aperture", 0.0); + let zoom = value("zoom", frame.focal * layer_scale); + // Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width, + // and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter). + let aperture_mm = aperture * 1920.0 / comp.width as f64; + let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 }; + let rotation = value("irisRotation", 0.0).to_radians(); + let ratio = value("irisAspectRatio", 1.0); + let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0); + let blades = match shape { + 0 => 4, + 1..=8 => shape.saturating_add(2), + _ => return Err(bad(CMD, "invalid iris shape")), + }; + let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0; + let source_time = ctx.source_time(layer); + let source_frame = if footage.kind == FootageKind::Still { + 0 + } else { + footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count + }; + finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?; + if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) { + return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range")); + } + frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame, + "location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof, + "fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio, + "sourceAperture": aperture, "sourceZoom": zoom, + "blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)})); + } + Ok((frames, world)) +} diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs new file mode 100644 index 000000000..a9454861f --- /dev/null +++ b/crates/engine/src/cmd/blender/validate.rs @@ -0,0 +1,71 @@ +//! Validate solved geometry and its correspondence to the current layer timing. + +use effectcraft_project::{Comp, Layer}; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; + +use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite}; +use crate::Result; + +pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> { + if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS { + return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot")); + } + if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) { + return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)")); + } + let fps = comp.frame_rate.as_f64(); + if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) { + return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)")); + } + let end = start + .checked_add(solve.frames.len() as i64) + .and_then(|n| n.checked_sub(1)) + .filter(|n| *n <= MAX_FRAME) + .ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?; + finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?; + if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 { + return Err(bad(CMD, "invalid composition dimensions or zero layer stretch")); + } + let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO)); + let hi = layer.out_point.min(comp.duration); + let fd = comp.frame_duration(); + if fd.0 <= 0 || lo >= hi { + return Err(bad(CMD, "the tracked layer has no active frames")); + } + let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128; + if expected != solve.frames.len() as u128 { + return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker")); + } + let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds(); + if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 { + return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker")); + } + Ok((lo, end)) +} + +pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> { + for frame in &solve.frames { + finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?; + if !frame.solved || frame.focal <= 0.0 { + return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker")); + } + let mm = sensor * frame.focal / solve.size[0]; + if !(1.0..=5000.0).contains(&mm) { + return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth")); + } + } + if let Some(ground) = &solve.ground { + finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?; + if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4 + || (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4 + || linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4 + { + return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again")); + } + } + if let Some(d) = &solve.distortion { + finite([d.k1, d.k2, d.radius])?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py new file mode 100644 index 000000000..0a9700a00 --- /dev/null +++ b/crates/engine/src/cmd/blender_scene.py @@ -0,0 +1,204 @@ +# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. +# Run in Blender's Scripting workspace (Open, Run Script), or: +# blender --background --python "Tracked Scene.py" +# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. +# Public API types: bpy.types.Camera, bpy.types.ImageUser +import json +import math +import os + +import bpy +from mathutils import Matrix + +DATA = json.loads(__EFFECTCRAFT_DATA__) + + +def _curves(owner): + """Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions.""" + anim = owner.animation_data + if anim is None or anim.action is None: + return + action = anim.action + if getattr(action, "is_action_layered", False): + for layer in action.layers: + for strip in layer.strips: + if strip.type == 'KEYFRAME': + bag = strip.channelbag(anim.action_slot) + if bag is not None: + yield from bag.fcurves + else: + yield from action.fcurves + + +def _interpolation(owner): + for curve in _curves(owner): + # Footage indices and discrete switches must never interpolate between frames. + stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path + or "aperture_blades" in curve.data_path) + for key in curve.keyframe_points: + key.interpolation = 'CONSTANT' if stepped else 'LINEAR' + + +def _media_path(): + original = DATA["media"]["path"] + candidates = [original] + # When sharing, place the video alongside the script or the .blend and run again. + script_path = globals().get("__file__", "") + if script_path: + candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)), + os.path.basename(original))) + if bpy.data.filepath: + candidates.append(os.path.join(os.path.dirname(bpy.data.filepath), + os.path.basename(original))) + return next((p for p in candidates if os.path.isfile(p)), None) + + +def import_scene(): + if bpy.app.version < (4, 0, 0): + raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer") + scene = bpy.data.scenes.new(DATA["name"]) + scene.frame_start = DATA["startFrame"] + scene.frame_end = DATA["endFrame"] + fps = DATA["fpsNumerator"] / DATA["fpsDenominator"] + scene.render.fps = max(1, round(fps)) + scene.render.fps_base = scene.render.fps / fps + scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"]) + scene.render.resolution_percentage = 100 + # The tracker assumes square pixels, regardless of the source's interpretation metadata. + scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0 + scene.render.film_transparent = True + scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"])) + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1.0 + scene["effectcraft_unit_scale"] = DATA["unitScale"] + scene["effectcraft_scale_is_arbitrary"] = True + scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"] + + collection = bpy.data.collections.new("EffectCraft Tracking") + scene.collection.children.link(collection) + camera_data = bpy.data.cameras.new("3D Tracker Camera") + camera = bpy.data.objects.new("3D Tracker Camera", camera_data) + collection.objects.link(camera) + scene.camera = camera + camera.rotation_mode = 'QUATERNION' + camera_data.type = 'PERSP' + camera_data.sensor_fit = 'HORIZONTAL' + camera_data.sensor_width = DATA["sensorWidth"] + camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1] + / DATA["resolution"][0]) + camera_data.show_background_images = True + camera_data.show_limits = True + extent = max([DATA["groundSize"], 1.0] + + [math.dist(p["location"], f["location"]) + for p in DATA["points"][::max(1, len(DATA["points"]) // 100)] + for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]]) + camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0)) + camera_data.clip_end = max(1000.0, extent * 10.0) + + previous = None + for sample in DATA["frames"]: + frame = sample["frame"] + camera.location = sample["location"] + # Exported right/up/back vectors are COLUMNS of Blender's local → world basis. + quaternion = Matrix(sample["rotation"]).transposed().to_quaternion() + if previous is not None and previous.dot(quaternion) < 0: + quaternion.negate() + camera.rotation_quaternion = quaternion + previous = quaternion.copy() + camera.keyframe_insert(data_path="location", frame=frame) + camera.keyframe_insert(data_path="rotation_quaternion", frame=frame) + camera_data.lens = sample["lens"] + camera_data.keyframe_insert(data_path="lens", frame=frame) + camera_data.dof.use_dof = sample["dof"] + camera_data.dof.focus_distance = sample["focus"] + camera_data.dof.aperture_fstop = sample["fstop"] + camera_data.dof.aperture_blades = sample["blades"] + camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi + camera_data.dof.aperture_ratio = sample["irisRatio"] + for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades", + "aperture_rotation", "aperture_ratio"): + camera_data.keyframe_insert(data_path="dof." + prop, frame=frame) + + path = _media_path() + if path is not None: + try: + image = bpy.data.images.load(path, check_existing=False) + image.source = DATA["media"]["kind"] + background = camera_data.background_images.new() + background.source = 'IMAGE' + background.image = image + background.alpha = 1.0 + background.display_depth = 'BACK' + background.frame_method = 'STRETCH' + user = background.image_user + user.use_auto_refresh = True + user.use_cyclic = False + user.frame_start = DATA["startFrame"] + user.frame_duration = max(1, min(1048574, image.frame_duration)) + if image.source == 'MOVIE': + for sample in DATA["frames"]: + # Blender uses 1-based image frames; sourceFrame is 0-based. + user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start) + camera_data.keyframe_insert( + data_path="background_images[0].image_user.frame_offset", + frame=sample["frame"]) + except (RuntimeError, ValueError, OSError) as error: + DATA["warnings"].append("Blender could not load the background video: " + str(error)) + else: + DATA["warnings"].append("Video not found: " + DATA["media"]["path"] + + ". Place it beside this script or relink the camera background.") + + if DATA["points"]: + mesh = bpy.data.meshes.new("Solved Track Points") + mesh.from_pydata([p["location"] for p in DATA["points"]], [], []) + mesh.update() + cloud = bpy.data.objects.new("Solved Track Points", mesh) + collection.objects.link(cloud) + cloud.show_in_front = True + cloud.hide_render = True + for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"), + ("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")): + attr = mesh.attributes.new(name=name, type=kind, domain='POINT') + for dest, point in zip(attr.data, DATA["points"]): + # Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too. + dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key]) + + if DATA["ground"]: + size = DATA["groundSize"] + mesh = bpy.data.meshes.new("Solved Ground Plane") + mesh.from_pydata([(-size, -size, 0), (size, -size, 0), + (size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)]) + mesh.update() + ground = bpy.data.objects.new("Solved Ground Plane", mesh) + collection.objects.link(ground) + ground.display_type = 'WIRE' + ground.hide_render = True + for marker in DATA["markers"]: + scene.timeline_markers.new(marker["name"], frame=marker["frame"]) + + _interpolation(camera) + _interpolation(camera_data) + metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json") + metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2)) + scene["effectcraft_metadata_text"] = metadata.name + camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"] + camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"] + camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name + distortion = DATA["solve"].get("distortion") + if distortion: + for key in ("k1", "k2", "radius"): + camera_data["effectcraft_distortion_" + key] = distortion[key] + if bpy.context.window is not None: + bpy.context.window.scene = scene + for area in bpy.context.window.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.region_3d.view_perspective = 'CAMERA' + scene.frame_set(scene.frame_start) + for warning in DATA["warnings"]: + print("EffectCraft: " + warning) + print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name) + return scene + + +if __name__ == "__main__": + EFFECTCRAFT_SCENE = import_scene() diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index 68cd14fb9..e717d51cc 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -42,11 +42,25 @@ pub fn specs() -> Vec { create_from_solve ), cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))), + cmd!( + "camera.exportBlender", + "Export Scene to Blender...", + [], + None, + "{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}", + has_comp, + export_blender + ), cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground), cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points), ] } +fn export_blender(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?; + super::blender::export(s, p, cid, lid, uid) +} + fn with_kind(p: &Value, kind: &str) -> Value { let mut q = p.clone(); if !q.is_object() { diff --git a/crates/engine/src/cmd/comp.rs b/crates/engine/src/cmd/comp.rs index 6648a7426..8bc0931f8 100644 --- a/crates/engine/src/cmd/comp.rs +++ b/crates/engine/src/cmd/comp.rs @@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, dx, dy); } diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index d6de320ec..83c49bad2 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -5,7 +5,7 @@ use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; use crate::prefs::CompPreset; use crate::{EngineError, Result, Session, cmd, query}; @@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & let (old_w, old_h) = (c.width, c.height); c.width = w; c.height = h; - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, -x, -y); } @@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { - use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan}; use effectcraft_project::render_templates::TemplateSlot; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if b_p(p, "transparent") == Some(false) { + it.output.channels = Channels::Rgb; + } if let Some(path) = str_p(p, "path") { it.output.output = path.to_string(); } else if !it.output.format.is_sequence() { @@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { } fn save_frame(s: &mut Session, p: &Value) -> Result { - if p.get("queue").and_then(Value::as_bool) == Some(true) { + if b_p(p, "queue") == Some(true) { return queue_frame(s, p); } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); @@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved frame to {path}")); @@ -235,7 +238,7 @@ pub fn specs() -> Vec { "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), - "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}", has_comp, save_frame ), diff --git a/crates/engine/src/cmd/content_fill.rs b/crates/engine/src/cmd/content_fill.rs index 650df5e73..d96be073d 100644 --- a/crates/engine/src/cmd/content_fill.rs +++ b/crates/engine/src/cmd/content_fill.rs @@ -170,6 +170,21 @@ pub struct FillPlan { pub dir: String, } +/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled +/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to +/// report up front, instead of running out of memory partway through. +fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option { + let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33); + (need > available).then(|| { + let gb = |b: u64| b as f64 / 1e9; + format!( + "Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications", + gb(need), + gb(available) + ) + }) +} + /// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the /// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files. #[allow(clippy::too_many_arguments)] @@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result { } let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1))); let times: Vec = (fa..=fb).map(|f| fr.tick_of(f)).collect(); + if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) { + return Err(bad(c, e)); + } let opts = FillOpts { method: FillMethod::from_name(&st.method).unwrap_or_default(), lighting: lighting(&st.lighting_correction).unwrap_or(0.0), @@ -424,3 +442,18 @@ pub fn specs() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::memory_shortfall; + + /// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold. + #[test] + fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() { + let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused"); + assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}"); + assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits"); + // Hostile sizes saturate instead of overflowing. + assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some()); + } +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 850d38ada..6c4b02c87 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, -//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). +//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer +//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to). use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result { let time = s.time(); let mut created = vec![]; let mut skipped = vec![]; + // All the selected models in one edit: one undo (or redo) removes (or restores) every + // instance at once, as one command in the History panel. + if op == "instance3d" { + // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take + // no masks or effects, so a comp-sized solid goes at the top of the layer stack + // with a 3D Layer Instance effect referencing the model — a 2D copy of its render, + // through the comp's camera, that 2D effects can be applied to. The model is switched + // off, as After Effects does (back on to keep its shadow). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut eligible = vec![]; + for lid in &lids { + match comp.layer(*lid) { + Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())), + Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)), + None => {} + } + } + if !eligible.is_empty() { + let ids = s.edit("Create 3D Layer Instance", None, |proj, st| { + let mut ids = vec![]; + for (lid, src) in &eligible { + let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + src.label, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + // A soloed source keeps its Solo switch with video off, and any_solo hides + // the non-soloed copy: the copy takes the Solo over to stay visible. + l.switches.solo = src.switches.solo; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?; + let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("layer") { + pr.value = KV::Layer(Some(lid.0)); + } + if let Some(fxg) = l.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + // The model is switched off (After Effects turns the referenced layer off); + // the copies go at the top of the stack, selected. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) { + m.switches.video = false; + } + let id = l.id; + c.layers.insert(0, l); + ids.push(id); + } + st.selected_layers = ids.clone(); + Ok(ids) + })?; + created.extend(ids.iter().map(|i| i.0)); + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + return Ok(json!({"layers": created, "skipped": skipped})); + } for lid in lids { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let Some(src) = comp.layer(lid).cloned() else { continue }; @@ -198,5 +264,5 @@ fn insert_above( } pub fn specs() -> Vec { - vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0130aff70..dd1685722 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); @@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { let present: Vec = comp.layers.iter().map(|l| l.id).collect(); let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); - for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) { l.parent = l.parent.and_then(link); l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); } @@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { st.selected_layers = created.clone(); Ok(created) })?; + // Duplicating selects the copies, so an open text edit on a now-deselected layer ends + // (#2226): typing must not keep editing an unselected layer. + if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) { + super::text_edit::end_edit(s, &Value::Null)?; + } Ok(json!(new.iter().map(|l| l.0).collect::>())) } @@ -263,6 +268,7 @@ fn clear_clipboards(s: &mut Session) { s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.value_clipboard.clear(); + s.state.keyframe_text = None; s.state.clip_is_keys = false; } @@ -329,10 +335,12 @@ fn copy_properties(s: &mut Session) -> Result> { } let mut keys = s.state.selected_keys.clone(); let mut values = vec![]; + let mut statics = vec![]; for (lid, uid) in &leaves { let Some(l) = comp.layer(*lid) else { continue }; let Some(pr) = l.props.find(*uid) else { continue }; if pr.keys.is_empty() { + statics.push((*lid, *uid)); if let Some(path) = l.props.match_path_of(*uid) && !values.iter().any(|(p, _)| *p == path) { @@ -342,6 +350,7 @@ fn copy_properties(s: &mut Session) -> Result> { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } + let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics); clear_clipboards(s); let mut n = 0; if !keys.is_empty() { @@ -350,6 +359,7 @@ fn copy_properties(s: &mut Session) -> Result> { s.state.selected_keys = selected; n = r?.as_u64().unwrap_or(0); } + s.state.keyframe_text = text; let v = values.len(); s.state.value_clipboard = values; // Keys alone answer as `keys.copy` does (their count). @@ -675,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result { } } if let Some(comp) = proj.comp_mut(cid) { - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.label = lab; } } diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 0a1354fd6..32b0aaa2b 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -288,7 +306,16 @@ impl CryptoProbe { let comp = r.project.comp(cid).ok_or("no composition")?; let layer = comp.layer(lid).ok_or("no such layer")?; let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; - let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let time = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); let prefs = (o.nested_switches, o.draft_shadows); @@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; let (cid, lid, ..) = key.at; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); @@ -700,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(sel) } -fn effect_time_error(cmd: &str) -> EngineError { +pub(crate) fn effect_time_error(cmd: &str) -> EngineError { bad(cmd, "an effect keyframe converts outside the supported time range") } @@ -736,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result Result) -> Result<()> { +pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result) -> Result<()> { let mut result = Ok(()); group.walk_mut(&mut |pr| { if result.is_err() { @@ -804,6 +849,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.keyframe_text = None; s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; diff --git a/crates/engine/src/cmd/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/cmd/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..e1f58a1cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. pub(crate) fn file_text(s: &Session, path: &str) -> Result { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } @@ -124,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -174,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -191,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -219,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -745,7 +785,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index c9d581581..c5018ae7c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; @@ -594,6 +601,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -687,9 +819,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -749,6 +895,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -878,10 +1036,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string(); let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?; let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - // A numbered still brings its image sequence, as on import (`sequence: false` for one frame). - let opts = SequenceOptions::from_params(p); + // Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run. + // `sequence`/`alphabetical` override that (#2073). + let previous = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical), + _ => (false, false, false), + }; + // Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence + // modifier, not a request to make one. + let inherited = b_p(p, "sequence").is_none(); + let mut opts = SequenceOptions::from_params(p); + if inherited { + (opts.sequence, opts.folders) = (previous.0, previous.1); + if previous.0 && b_p(p, "alphabetical").is_none() { + opts.alphabetical = previous.2; + } + } let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); - let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A single-file source comes back as a still; an inherited sequence kind keeps it a + // one-frame sequence, so a later default replacement still brings numbered runs (#2073). + if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still { + f.kind = FootageKind::Sequence; + f.sequence = vec![f.path.clone()]; + f.alphabetical = opts.alphabetical; + f.frame_rate = s.sequence_rate(); + f.native_rate = None; + f.sync_sequence_duration(); + } let name = src.name(); // Replacing a missing file relinks the other missing files that moved with it (as After Effects // does when you locate one). @@ -1066,7 +1248,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), @@ -1084,7 +1266,7 @@ pub fn specs() -> Vec { "Apply Interpretation", ["File", "Interpret Footage"], Some("Cmd+Alt+V"), - "{items?}", + "{items?|item?}", has_interpretation, apply_interpretation ), @@ -1093,7 +1275,7 @@ pub fn specs() -> Vec { "File...", ["File", "Replace Footage"], Some("Cmd+H"), - "{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}", + "{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}", has_footage_selection, replace_footage ), @@ -1115,7 +1297,7 @@ pub fn specs() -> Vec { has_footage_selection, replace_with_solid ), - cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload), + cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload), cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder), ] } diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/cmd/focus.rs +++ b/crates/engine/src/cmd/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs index 1bd0d2236..2ec9f4be2 100644 --- a/crates/engine/src/cmd/footage_panel.rs +++ b/crates/engine/src/cmd/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { @@ -207,10 +213,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f6d2feb04..eb9641547 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)", + "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", has_comp, new_text ), @@ -121,7 +121,7 @@ pub fn specs() -> Vec { "Track Matte", [], None, - "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", + "{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}", has_layers, track_matte ), @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ), @@ -192,7 +192,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7962c2485..ccc4810a9 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option> { /// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 /// characters of the first text line, else "Text". `edit: true` also opens a new editing /// session with anchor `0` and the caret after the text, selecting all initial text so the next -/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does. pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index fbaec14e4..870d22f5f 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -9,10 +9,20 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -22,7 +32,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -44,7 +54,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -64,34 +75,19 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); } - // Cameras and lights aim with Point of Interest: move it by the same parent-space - // change, so a two-node rig keeps its world frame (#1010, #1020). if let Some(pr) = tr.get_mut("poi") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } + map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -154,13 +150,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + budget: usize, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); +} + +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); + // With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between + // the converted keys, rather than becoming a straight blend that was never checked against the original. + if between.len() >= budget { + between.clear(); + // An eased segment, or one that gained keys, becomes a run of straight blends. + } else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, @@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; @@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - // Cameras and lights compensate like 3D layers (Position, and Point of Interest for - // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no - // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - if !orientation.keys.is_empty() - || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); + if orientation.expr.is_some() + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } @@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + // Locked layers keep their parent (several at once: the others are parented). + let ids = unlocked(s, cid, ids, "layer.setParent")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let parent = match p.get("parent") { None | Some(Value::Null) => None, @@ -323,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.parent = parent; if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { f.apply(l); diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 38689afbb..366c1eeb1 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,6 +1,6 @@ use super::{ BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, - layers_p, merge_p, resolve_layer, str_p, + layers_p, merge_p, resolve_layer, str_p, unlocked, }; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { @@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; let target = v.unwrap_or(!first); - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { set_switch_value(l, &name, target); last = target; } @@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, alone &= on == (ids.contains(&l.id) == active); } let others = value.unwrap_or(alone == active); - for l in comp.layers.iter_mut().filter(|l| applies(l)) { + for l in comp.layers.iter_mut_where(|l| applies(l)) { let v = if ids.contains(&l.id) { active } else { others }; set_switch_value(l, name, v); } @@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); s.edit("Layer Comment", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.comment = text.clone(); } Ok(()) @@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { } s.edit("Blending Mode", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.blend_mode = match mode { Some(m) => m, None if step > 0 => l.blend_mode.next(), @@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left +/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps +/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers +/// at once keeps their modes. pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; + let (cid, ids) = match p.get("layers") { + Some(_) => layers_p(s, p)?, + None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?, + }; + // Locked layers keep their matte (several at once: the others get it). + let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), }; - let kind = str_p(p, "kind") - .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + if ids.is_empty() { + return Err(bad("layer.setTrackMatte", "no layers")); + } + if matte.is_some_and(|m| ids.contains(&m)) { + return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); + } + // None: each layer keeps its own mode. + let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) { + Some(k) if k == "preserve" => None, + Some(k) => Some(match k.as_str() { "alphainverted" | "alphainv" => MatteKind::AlphaInverted, "luma" => MatteKind::Luma, "lumainverted" | "lumainv" => MatteKind::LumaInverted, _ => MatteKind::Alpha, - }) - .unwrap_or(MatteKind::Alpha); + }), + None => Some(MatteKind::Alpha), + }; s.edit("Track Matte", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - if let Some(m) = matte { - if m == lid { - return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); - } - if let Some(ml) = comp.layer_mut(m) { - ml.switches.video = false; - } + if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) { + ml.switches.video = false; + } + for lid in &ids { + let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?; + let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default()); + l.track_matte = matte.map(|layer| TrackMatte { layer, kind }); } - comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); Ok(()) })?; Ok(Value::Null) } + +#[cfg(test)] +mod track_matte_batch_tests { + use super::*; + use std::sync::Arc; + + fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) + } + + /// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"` + /// keeps each one's mode; refused calls change nothing; one `layer` still works. + #[test] + fn several_layers_take_one_matte_and_keep_their_modes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before"); + let (before, undo) = (s.project.clone(), s.history.undo.len()); + s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap(); + assert_eq!( + [matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)], + [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))], + "each keeps its mode (Alpha without one)" + ); + assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); + // Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes. + for p in [ + json!({"layers": [a, y], "matte": y}), + json!({"layers": [a, 9999], "matte": y}), + json!({"layers": [], "matte": y}), + json!({"layers": [a], "matte": 9999}), + ] { + let (project, revision) = (s.project.clone(), s.revision); + assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing"); + } + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it"); + } + + /// Locked layers keep their parent and matte when they are among several (the others change), + /// and alone they are an error that changes nothing. + #[test] + fn locked_layers_keep_their_parent_and_matte() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap(); + s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap(); + assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma)))); + assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was"); + for (cmd, p) in [ + ("layer.setParent", json!({"layers": [b], "parent": p})), + ("layer.setTrackMatte", json!({"layers": [b], "matte": m})), + ("layer.trackMatte", json!({"layers": [b], "op": "alpha"})), + ] { + let (project, revision) = (s.project.clone(), s.revision); + let err = s.execute(cmd, p).unwrap_err(); + assert!(err.to_string().contains("is locked"), "{cmd}: {err}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing"); + } + } +} diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 711eefee2..0427dcf0d 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); let comp_dur = comp.duration; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; @@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { shift(l, d); } "trimInToTime" => l.in_point = t.min(l.out_point - fd), - "trimOutToTime" => l.out_point = t.max(l.in_point + fd), + // Alt+]: the Out edge lands one frame after the playhead, so its frame stays the + // layer's last visible one (as in After Effects), never before the In point. + "trimOutToTime" => l.out_point = t.max(l.in_point) + fd, _ => { if let Some(d) = delta { shift(l, d); @@ -159,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { for i in 1..n { if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); - comp.layers[i - 1..run_end].rotate_left(1); + comp.layers.rotate_range_left(i - 1..run_end, 1); } } } else { for i in (0..n.saturating_sub(1)).rev() { if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); - comp.layers[run_start..=i + 1].rotate_right(1); + comp.layers.rotate_range_right(run_start..=i + 1, 1); } } } diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 8b75dcbc8..eb0f610f9 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + // A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by + // its painted bounds at the current time instead, and anchor at the bounds' centre — content + // bounds live in layer space, where a shape is centred on the origin and text sits above its + // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the + // source size. + let ectx = effectcraft_render::EvalCtx { + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + ..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t) + }; + let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); + let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id)) + .map(|l| { + let (w, h) = effectcraft_render::source_size(&s.project, l); + if fitting && (w == 0 || h == 0) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) { + Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), + None => (l.id, [0.0; 2], [0.0; 2]), + } + } else { + (l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0]) + } + }) + .collect(); s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { + for (lid, size, anchor) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); + let (w, h) = (size[0], size[1]); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { @@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; + // A tiny shape's bounds can overflow the scale; refuse rather than write infinity. + if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() { + return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0))); + } let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index 33d17bb4f..e0c4efc72 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result { q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))), }; s.edit("Layer Quality", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.quality = q; } Ok(()) @@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result { q => q, }; s.edit("Layer Sampling", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.sampling = q; } Ok(()) @@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))), }; s.edit("Frame Blending", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.frame_blend = m; } Ok(()) @@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { s.edit("Hide Other Video", None, |proj, _| { // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's // eye cell shows): the selection stays shown. - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) { l.switches.video = false; } Ok(()) @@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; // Track mattes stay hidden (their video switch is off by design). let mattes: Vec = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect(); - for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) { l.switches.video = true; } Ok(()) @@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "enabled").unwrap_or(true); let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| { let mut n = 0; - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.props.walk_mut(&mut |pr| { if let Some(e) = &mut pr.expr { e.enabled = on; @@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { let r = s.edit("Lock Markers", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked)); - for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.markers_locked = target; } Ok(target) @@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { fn delete_all_markers(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Delete All Markers", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) { l.markers.clear(); } Ok(()) @@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result { fn track_matte_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?; let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string(); let kind = match op.as_str() { "alpha" => Some(MatteKind::Alpha), diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..4ecf725ec 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let old = l.stretch; if reverse { // Keep the in/out points; play the visible span backwards. @@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut on = false; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) { let has = l.props.get("timeRemap").is_some(); let target = want.unwrap_or(!has); on |= target; @@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); } // What each layer shows now (honouring an existing remap) — evaluated before the edit. - let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: cti, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers .iter() diff --git a/crates/engine/src/cmd/link.rs b/crates/engine/src/cmd/link.rs index 2eeb7c5a0..66145f17e 100644 --- a/crates/engine/src/cmd/link.rs +++ b/crates/engine/src/cmd/link.rs @@ -102,11 +102,39 @@ fn quote(s: &str) -> String { /// The AE expression reference to property `uid` of `target` as seen from a property of `from`. pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option { + reference_with(comp, from, target, uid, compact, false) +} + +/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an +/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The +/// effect keeps its name. An index is the property's place among its group's children, as an +/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place. +pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option { let chain = target.props.node_chain(uid)?; + // Each node's one-based index among its group's children, and whether it lies inside an + // effect (below the effect itself): those are selected by index with `by_index`. + let mut parent = &target.props; + let mut depth_in_effects: Option = None; + let mut by_number: Vec> = vec![]; + for n in &chain { + let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1); + by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1))); + depth_in_effects = match depth_in_effects { + Some(d) => Some(d + 1), + None => (n.match_id() == "effects").then_some(0), + }; + if let Node::Group(g) = n { + parent = g; + } + } + let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() { + Some(at) => format!("({at})"), + None => format!("({})", quote(label)), + }; let mut parts: Vec = vec![]; let mut pending: Option<&str> = None; let mut in_effect = false; - for n in chain { + for (i, n) in chain.iter().copied().enumerate() { if let Some(f) = pending.take() { parts.push(format!("{f}({})", quote(n.name()))); in_effect |= f == "effect"; @@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com (Node::Group(_), "transform") if !in_effect => parts.push("transform".into()), (Node::Group(_), "text") if !in_effect => parts.push("text".into()), (Node::Prop(_), "sourceText") => parts.push("sourceText".into()), - _ if in_effect => parts.push(format!("({})", quote(n.name()))), + _ if in_effect => parts.push(selector(i, n.name())), _ => parts.push(camel(n.name())), } } @@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com let _ = comp; if !compact { // Match names: language-independent, like After Effects with Compact English off. - let chain = target.props.node_chain(uid)?; let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) }; - for n in chain { - out.push_str(&format!("({})", quote(n.match_id()))); + for (i, n) in chain.iter().copied().enumerate() { + out.push_str(&selector(i, n.match_id())); } return Some(out); } @@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let from = comp.layer(lid).ok_or(EngineError::NoComp)?; let target = comp.layer(tl).ok_or(EngineError::NoComp)?; - let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let by_index = b_p(p, "byIndex").unwrap_or(false); + let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index) + .ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); // Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects. @@ -206,7 +235,7 @@ pub fn specs() -> Vec { "Pick Whip (Link Property)", [], None, - "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)", + "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)", has_layers, pick_whip ), diff --git a/crates/engine/src/cmd/markers.rs b/crates/engine/src/cmd/markers.rs index 4c979619e..3530eaa4b 100644 --- a/crates/engine/src/cmd/markers.rs +++ b/crates/engine/src/cmd/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { if l.markers_locked { continue; } + // One set of the times already on the layer: a file with thousands of markers must not + // scan the layer's list for each of them. + let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect(); for m in ms { - if !l.markers.iter().any(|x| x.time == m.time) { + if times.insert(m.time) { l.markers.push(m.clone()); n += 1; } @@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/engine/src/cmd/mask_interp.rs b/crates/engine/src/cmd/mask_interp.rs index 982468c8e..e2b53f8a4 100644 --- a/crates/engine/src/cmd/mask_interp.rs +++ b/crates/engine/src/cmd/mask_interp.rs @@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result { clip.push(crate::KeyClip { layer: lid, path, keys }); } s.state.key_clipboard = clip; + s.state.keyframe_text = None; s.state.clip_is_keys = true; // The same data as text (tab-separated, one row per frame) for other applications. let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime"); diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index 28b7274ef..2cc4d34e6 100644 --- a/crates/engine/src/cmd/model3d.rs +++ b/crates/engine/src/cmd/model3d.rs @@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); } + if r == Renderer::Classic3D { + let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment }); + if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) { + s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer"); + } + } Ok(json!({"renderer": renderer_id(r)})) } @@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str { /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed. -fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { +pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let switched = c.renderer != Renderer::Advanced3D; c.renderer = Renderer::Advanced3D; @@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { +pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); - s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}")); } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index bb74ee77c..93d71cd67 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Result { /// 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 { - 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()); +/// The path a parametric shape group draws, in the group's own space. This is the geometry the +/// renderer builds from the same values (`render::shapes::collect`), expressions included, 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(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option { + let f = |id: &str, d: f64| ctx.f(layer, g, id, d); + let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d); 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)), + "rect" => { + let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2])); + if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)], + ) + } else { + effectcraft_path::rect(size, pos, f("roundness", 0.0)) + } + } "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), "star" => effectcraft_path::polystar(effectcraft_path::Polystar { - star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + star: ctx.e(layer, g, "type") == 0, points: f("points", 5.0), pos: v2("position", [0.0; 2]), rotation: f("rotation", 0.0), @@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { } let t = s.time(); let n = shapes.len(); + // Read the geometry before editing, with the same expression-aware context the renderer + // uses: an expression-driven corner radius, size or position must convert to what is on + // screen, not to the property's underlying value. + let prepared = { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) }; + let mut prepared = Vec::new(); + for (lid, uid) in &shapes { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue }; + let Some(path) = parametric_path(&ctx, l, old) else { continue }; + // Path Direction is the shape's own, not a default: reversing is a separate command. + prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled)); + } + prepared + }; 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); + for (lid, uid, path, direction, enabled) in prepared { + let l = layer_mut(proj, cid, lid)?; 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 }; + 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)); + made.push((lid, new_uid)); } proj.next_id = next; // The new paths are what is selected, so the vertices can be edited straight away. @@ -308,7 +331,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { let y = v[1] * scale[1]; [x * c - y * sn, x * sn + y * c] }; - let n = s.edit("Free Transform Points", None, |proj, _| { + let n = s.edit("Free Transform Points", merge_p(p), |proj, _| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; @@ -360,7 +383,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", - "wiggle", "merge", "front", "bevel", "side", "back", + "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid). @@ -542,7 +565,7 @@ pub fn specs() -> Vec { "Free Transform Points", ["Layer", "Mask and Shape Path"], None, - "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}", + "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}", has_layers, free_transform ), diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..663429fb2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +fn set_muted(s: &mut Session, p: &Value) -> Result { + let muted = b_p(p, "muted").unwrap_or(true); + let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" }; + edit_keys(s, label, None, move |keys, i, _| { + keys[i].muted = muted; + false + }) +} + /// A number or a per-dimension array of numbers. fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { match p.get(k)? { @@ -1285,8 +1294,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res let fd = comp.frame_duration(); let mut t = layer.in_point; while t < layer.out_point { - let ctx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp: &comp, + time: t, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } @@ -1373,6 +1390,7 @@ pub fn specs() -> Vec { &json!({"which": "out"}) )), cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold), + cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted), cmd!( "keys.interpolation", "Keyframe Interpolation...", diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index b7d644b94..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 320e1d415..1d080e773 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 1cda510e0..00e078372 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { } /// The `format` parameter's values. -const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; +const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff"; /// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`. fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { @@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { @@ -1017,7 +1017,7 @@ pub fn specs() -> Vec { "Add to Render Queue", ["Composition"], ["Cmd+M", "Cmd+Shift+/"], - "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", + "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add ), @@ -1037,7 +1037,7 @@ pub fn specs() -> Vec { "Output Module Settings...", [], None, - "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", + "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", has_items, set_output_module ), diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..dbcaa3c3c 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -48,7 +48,16 @@ impl Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { - EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + EvalCtx { + project: proj, + comp_id: cid, + comp, + time: self.time, + expr: self.expr.as_deref(), + footage: Some(self.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + } } } @@ -268,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { set_expr(&mut cam, "cameraOptions/zoom", ze); cam.auto_orient = AutoOrient::TowardsPointOfInterest; let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?; - comp.layers = vec![cam, nest]; + comp.layers = vec![cam, nest].into(); eye_comps.push(eid); } // The output comp: both eyes through 3D Glasses. @@ -291,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { fxg.children.push(g.into()); } let (lid, rid) = (left.id, right.id); - proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right]; + proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into(); st.project_selection = vec![oid]; Ok(json!({ "controls": ctl_id.0, diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 635429d6b..8c14e03ec 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -5,7 +5,11 @@ //! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts //! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels -//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of +//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -22,6 +26,23 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; +/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. +const PANELS_FOLDER: &str = "ScriptUI Panels"; +/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in +/// After Effects): never listed as File ▸ Scripts submenus. +const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"]; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects @@ -111,7 +132,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[ /// A script File ▸ Scripts (or the Window menu, for panels) offers. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ScriptEntry { - /// File name (`Rename Layers.jsx`): what the menus show and commands take. + /// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take. pub name: String, /// In the ScriptUI Panels folder: opens as a dockable panel. pub panel: bool, @@ -119,16 +140,23 @@ pub struct ScriptEntry { pub source: &'static str, } +/// Whether `name` ends in `.jsx` or `.js` (any letter case). fn is_script(name: &str) -> bool { let l = name.to_ascii_lowercase(); l.ends_with(".jsx") || l.ends_with(".js") } -/// Whether `name` can name an installed script: a bare file name, never a path (names are joined -/// onto the Scripts folders of the settings store, which takes them as relative paths), and on -/// Windows none of the names [`windows_unsafe_name`] lists. +/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a +/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI +/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of +/// the settings store, which takes them as relative paths), and on Windows none of the names +/// [`windows_unsafe_name`] lists. fn is_script_name(name: &str) -> bool { - is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name)) + let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n)); + match name.split_once('/') { + Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)), + None => part(name), + } } /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. @@ -155,8 +183,13 @@ pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { - for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) { - v.push(ScriptEntry { name: n, panel, source: "installed" }); + // The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`. + let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/"))); + for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) { + let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}")); + for n in names.filter(|n| is_script(n) && is_script_name(n)) { + v.push(ScriptEntry { name: n, panel, source: "installed" }); + } } } } @@ -203,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -216,12 +250,19 @@ fn list(s: &mut Session, _: &Value) -> Result { Ok(json!(scripts(s))) } +/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts +/// folders. fn uninstall(s: &mut Session, p: &Value) -> Result { let c = "file.uninstallScript"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?; if !is_script(name) || !is_script_name(name) { return Err(bad(c, format!("`{name}` is not a script file name"))); } + // A subfolder can be a link to files outside the settings folder (an extension's own + // folder), so only what Install puts at the top is removed, never a file through a folder. + if name.contains('/') { + return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)"))); + } let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { @@ -231,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -312,17 +354,98 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let hosts_before = s.script_ui.task_hosts.clone(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + if !quitting { + // The Startup script's context: the one that scheduled tasks or holds the windows it + // opened (a task scheduled after its dialog closes runs there too). + let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied(); + let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host); + let hosts: Vec = tasks.chain(windows).collect(); + for h in hosts { + if !s.script_ui.startup_hosts.contains(&h) { + s.script_ui.startup_hosts.push(h); + } + } + } + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); - let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); + let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f); + let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) { let id = w.id; s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); return Ok(json!({"window": id, "open": true})); } - let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; + // Only panels: a script opened as one would be remembered, then forgotten at the next launch. + let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?; let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id); if let Some(id) = id { @@ -338,6 +461,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result { Ok(json!({"window": id, "result": out})) } +/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings. pub fn specs() -> Vec { vec![ cmd!( @@ -358,14 +482,22 @@ pub fn specs() -> Vec { always, |s, p| install(s, p, true) ), - cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), - crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list), + cmd!( + "file.uninstallScript", + "Uninstall Script", + [], + None, + "{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}", + always, + uninstall + ), + crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list), cmd!( "window.scriptPanel", "ScriptUI Panel", [], None, - "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel", + "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel", always, open_panel ), @@ -378,6 +510,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", @@ -427,10 +577,19 @@ pub fn specs() -> Vec { "Mouse Event on Script Window Control", [], None, - "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", + "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", always, scriptui::mouse ), + cmd!( + "scriptui.resize", + "Resize Script Window", + [], + None, + "{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)", + always, + scriptui::resize + ), cmd!( "scriptui.close", "Close Script Window", @@ -504,13 +663,15 @@ mod tests { } } + /// Bundled and ordinary script file names are accepted; paths are not. #[test] fn script_names_are_bare_file_names() { let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"]; for name in BUNDLED.iter().map(|b| b.0).chain(more) { assert!(is_script(name) && is_script_name(name), "{name}"); } - for name in PATHS { + // `a/b.jsx` is a script in a folder (see below). + for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") { assert!(!is_script_name(name), "{name:?}"); } for name in WINDOWS_ONLY { @@ -528,6 +689,141 @@ mod tests { } } + /// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not. + #[test] + fn script_names_may_have_one_folder() { + for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] { + assert!(is_script_name(name), "{name:?}"); + } + // Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`. + let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"]; + for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) { + assert!(!is_script_name(name), "{name:?}"); + } + for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] { + assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}"); + } + } + + /// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`. + fn put(root: &std::path::Path, rel: &str, code: &str) { + let p = root.join("settings").join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, code).unwrap(); + } + + /// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level. + #[test] + fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() { + let (mut s, root) = session_in_tmp("folders"); + put(&root, "Scripts/Tools/Hello.jsx", "hello"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate"); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top"); + // Two folders deep: neither listed nor run. + put(&root, "Scripts/a/b/c.jsx", "too deep"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep"); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let installed: Vec<(&str, bool)> = + list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect(); + assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]); + assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello"); + // A panel in a folder is titled after its file. + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let ran = r["result"]["ran"].as_str().unwrap_or_default(); + assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + // A script that is not a panel doesn't open as one. + for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] { + assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}"); + } + for name in [ + "../x.jsx", + "a/b/c.jsx", + "a\\b.jsx", + "Motion Kit/sub/Deep.jsx", + "ScriptUI Panels/Top.jsx", + "Startup/Run.jsx", + "shutdown/Run.jsx", + "Tools/../../../outside.jsx", + ] { + assert_eq!(script_code(&s, name, None), None, "{name:?}"); + for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] { + let r = s.execute(c, json!({"name": name})); + assert!(bad_params(&r), "{c} `{name}`: {r:?}"); + } + } + assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + assert!(root.join("outside.jsx").is_file()); + // Uninstall removes only scripts at the top; one in a folder is deleted there by hand. + for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] { + assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}"); + } + assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + let _ = std::fs::remove_dir_all(&root); + } + + /// A subfolder can be a link to an extension's own folder, outside the settings folder: its + /// scripts are listed and run from there, but uninstall never deletes through it. + #[cfg(unix)] + #[test] + fn uninstall_never_deletes_through_a_linked_subfolder() { + let (mut s, root) = session_in_tmp("linked"); + std::fs::create_dir_all(root.join("extension")).unwrap(); + std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap(); + std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"})); + assert!(bad_params(&r), "{r:?}"); + assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder + /// sorts; the entries outside folders stay as they were. + #[test] + fn a_subfolder_is_a_submenu() { + use crate::menus::{MenuNode, dynamic, dynamic_nodes}; + let (s, root) = session_in_tmp("submenus"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", ""); + put(&root, "Scripts/Tools/Hello.jsx", ""); + let cx = Default::default(); + let item = + |label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null}); + let menus = [ + ( + "scriptPanels", + json!({"type": "submenu", "label": "Motion Kit", "children": [ + item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"), + item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"), + ]}), + ), + ("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})), + ]; + for (menu, folder) in menus { + let (nodes, _) = dynamic_nodes(&s, menu, &cx); + let (entries, _) = dynamic(&s, menu, &cx); + let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect(); + assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::>(), "{menu}"); + let folders: Vec = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect(); + assert_eq!(folders, [folder], "{menu}"); + } + let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx); + let labels: Vec<&str> = panels + .iter() + .filter_map(|n| match n { + MenuNode::Item(e) => Some(e.label.as_str()), + MenuNode::Submenu { label, .. } => Some(label), + _ => None, + }) + .collect(); + assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn uninstall_takes_a_script_name_not_a_path() { let (mut s, root) = session_in_tmp("uninstall"); @@ -775,4 +1071,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index e1f3607b1..3323d61d6 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] { [c[0] as f64, c[1] as f64, c[2] as f64, 1.0] } -/// The Rounded Rectangle tool's corner roundness for `size`. -fn roundness(size: [f64; 2]) -> f64 { - size[0].min(size[1]) * 0.15 +/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects +/// (it does not grow with the drawn size). +fn roundness() -> f64 { + 20.0 } /// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius). fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { match kind { "star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)), - "polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)), + "polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)), _ => None, } } @@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option { let (path, gname) = match kind { "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), - "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), k => { let (star, points, outer, inner) = polystar_of(k, size)?; @@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), - "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)), + "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()), k => { let (star, points, outer, inner) = polystar_of(k, size)?; effectcraft_path::polystar(effectcraft_path::Polystar { diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 92ab23f17..296dc4c37 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index 0d9b25aec..cdb650486 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -71,7 +71,8 @@ pub struct TextEdit { /// Character panel changes made with only a caret: the style the next typed text takes. #[serde(default)] pub pending: Option, - /// The layer was made by a Type tool click; leaving it empty deletes it. + /// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this + /// only records how the edit began. #[serde(default)] pub created: bool, /// Line position kept by repeated up / down moves (layout space). @@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). +/// `text.endEdit`: stop editing. +/// +/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in +/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a +/// mistake to clean up. Deleting it took away something they could still type into. pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { - let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; - if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - s.edit("Delete Empty Text Layer", None, |proj, st| { - if let Some(c) = proj.comp_mut(cid) { - c.layers.retain(|l| l.id != e.layer); - } - st.selected_layers.retain(|l| *l != e.layer); - Ok(()) - })?; + if s.state.text_edit.take().is_none() { + return Ok(Value::Null); } s.bump(); Ok(Value::Null) diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..f7b1c3e08 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -12,6 +12,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, insert_layer}; +use super::model3d::{report_switch, use_advanced_3d}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; @@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); - let id = s.edit("New Light", None, |proj, st| { + // Only the Advanced 3D renderer lights with Environment lights, so adding one switches a + // Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). + let env = kind == LightKind::Environment; + let (id, switched) = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; - insert_layer(proj, st, cid, l) + let id = insert_layer(proj, st, cid, l)?; + Ok((id, if env { use_advanced_3d(proj, cid)? } else { false })) })?; - Ok(json!({"layer": id.0})) + report_switch(s, cid, switched, "which Environment lights need to light the scene"); + Ok(json!({"layer": id.0, "advanced3d": env})) } fn light_settings(s: &mut Session, p: &Value) -> Result { @@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { ), None => None, }; - s.edit("Light Settings", None, |proj, _| { + let switched = s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); if let Some(k) = kind @@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; + // A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D, + // like adding one does; editing one that already is leaves the renderer to the user. + let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment }); + let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - Ok(()) + Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false }) })?; + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) } @@ -413,7 +424,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -532,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { }; s.edit("Auto-Orient", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let rig = l.is_camera() || l.is_light(); let ok = match mode { AutoOrient::Off | AutoOrient::AlongPath => true, @@ -555,7 +567,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); @@ -681,7 +694,7 @@ pub fn specs() -> Vec { "Light...", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), - "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", + "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_light ), @@ -708,7 +721,7 @@ pub fn specs() -> Vec { "Light Settings...", [], None, - "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", + "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", has_comp, light_settings ), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 954a7d24e..c11f575b0 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -86,6 +86,10 @@ pub struct EditorState { /// Paste sets the same properties of the selected layers. #[serde(skip)] pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// The copied keyframes and values as After Effects keyframe text (one layer's Transform + /// properties), for the system clipboard. + #[serde(skip)] + pub keyframe_text: Option, /// Copied effect instances, with comp-time key offsets from the earliest copied key. /// Paste adds them to the selected layers at the destination comp's CTI. #[serde(skip)] diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 75fd0f936..16d7c1d5d 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -9,31 +9,35 @@ //! render reads the new pixels. //! //! The automatic part compares each footage file's size and modification time with what they -//! were when the file was last seen. The desktop app takes those stamps every two seconds (and -//! when its window comes back to the front) on a worker thread, so a file on a slow network share -//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them -//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload -//! records the state of the disk: it is not an undo step and doesn't modify the project. +//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that +//! stamps the files every two seconds while the window is in front (every ten seconds while it +//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp +//! changed, so a file on a slow network share never holds up the interface and an idle app never +//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot +//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records +//! the state of the disk: it is not an undo step and doesn't modify the project. use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; use serde_json::{Value, json}; -use crate::Session; +use crate::{Services, Session}; /// A file's size and modification time (see [`crate::Services::stamp`]). pub type Stamp = (u64, u128); -/// Stamps taken on a worker thread, waiting for [`poll_scan`]. -pub type Scan = Arc)>>>>; +/// The stamps of a set of files, in path order. +pub type Stamps = Vec<(String, Option)>; /// 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 { +pub fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { @@ -81,6 +85,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { nf.codec = probed.codec; nf.has_video = probed.has_video; nf.has_audio = probed.has_audio; + // The markers the file carries now (a WAV's cue points), for Update Markers From Source. + nf.markers = probed.markers; if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { nf.duration = probed.duration; // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. @@ -265,31 +271,253 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { apply_stamps(s, now) } -/// Start stamping the watched files on a worker thread, unless a scan is still running (or the -/// build has no threads: the web app, whose footage lives in browser storage). -pub fn start_scan(s: &mut Session) { - if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { - return; +/// How often the watched files are stamped while the window is in front. +pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2); +/// How often they are stamped while another app has the front: a file saved there is noticed +/// as soon as the window comes back, so this only covers a second screen. +pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10); + +enum Watch { + Paths(BTreeSet), + Focused(bool), +} + +/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands +/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked, +/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it +/// what to watch and whether the window is in front; nothing watched means the thread sleeps. +/// Dropping the watcher ends the thread. +pub struct Watcher { + tx: Sender, + result: Arc>>, + paths: BTreeSet, + focused: Option, +} + +impl Watcher { + /// Start the worker, stamping through `services`; `wake` runs on the worker whenever new + /// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app, + /// whose footage lives in browser storage) or one couldn't be started. + pub fn start(services: Arc, wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL) } - let paths = watched(s); - if paths.is_empty() { - return; + + /// [`Watcher::start`] with its own stamping intervals (tests). + pub fn start_with(services: Arc, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::channel(); + let result: Arc>> = Arc::default(); + let out = Arc::clone(&result); + std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?; + Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None }) } - let services = s.services.clone(); - let slot: Scan = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { - let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); - }); - if spawned.is_ok() { - s.footage_scan = Some(slot); + + /// The files to watch ([`watched`]); a changed set is stamped at once. + pub fn set_watched(&mut self, paths: BTreeSet) { + if paths != self.paths { + self.paths = paths.clone(); + let _ = self.tx.send(Watch::Paths(paths)); + } + } + + /// Whether the window is in front; coming back to the front stamps the files at once. + pub fn set_focused(&mut self, focused: bool) { + if self.focused != Some(focused) { + self.focused = Some(focused); + let _ = self.tx.send(Watch::Focused(focused)); + } + } + + /// Stamps the worker found changed (or the first ones for a new set of files), once; for + /// [`apply_stamps`]. `None` while nothing changed. + pub fn take(&self) -> Option { + self.result.lock().unwrap_or_else(PoisonError::into_inner).take() } } -/// Apply a finished scan (`None` while there is none, or it is still running). -pub fn poll_scan(s: &mut Session) -> Option> { - let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; - s.footage_scan = None; - Some(apply_stamps(s, stamps)) +/// The watcher's worker: wait for a message or the interval, stamp, report only changes. +fn watch( + rx: &Receiver, + services: &dyn Services, + out: &Mutex>, + wake: &(dyn Fn() + Send + Sync), + focused_every: Duration, + unfocused_every: Duration, +) { + let mut paths = BTreeSet::new(); + let mut focused = true; + let mut last: Option = None; + loop { + let msg = if paths.is_empty() { + rx.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + rx.recv_timeout(if focused { focused_every } else { unfocused_every }) + }; + match msg { + Ok(Watch::Paths(p)) => { + paths = p; + // A new set is reported whole, so the session forgets files no longer watched. + last = None; + } + Ok(Watch::Focused(f)) => { + let regained = f && !focused; + focused = f; + if !regained { + continue; + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + if paths.is_empty() { + continue; + } + let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect(); + if last.as_ref() != Some(&stamps) { + last = Some(stamps.clone()); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + wake(); + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod watcher_tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + /// Files whose stamps the test sets; counts how often they were stamped. + #[derive(Default)] + struct Disk { + stamps: Mutex>, + looks: AtomicUsize, + } + impl Disk { + fn set(&self, path: &str, stamp: Stamp) { + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp); + } + } + impl Services for Disk { + fn read_file(&self, path: &str) -> std::io::Result> { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other("read-only")) + } + fn stamp(&self, path: &str) -> Option { + self.looks.fetch_add(1, Ordering::SeqCst); + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied() + } + } + + const TICK: Duration = Duration::from_millis(20); + const LONG: Duration = Duration::from_secs(60); + + fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps { + let t0 = std::time::Instant::now(); + loop { + if wakes.load(Ordering::SeqCst) >= n + && let Some(s) = w.take() + { + return s; + } + assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Wait (at most 10 s) until `ready`: a slow machine's worker gets there late, never early. + fn wait_until(what: &str, mut ready: impl FnMut() -> bool) { + let t0 = std::time::Instant::now(); + while !ready() { + assert!(t0.elapsed() < Duration::from_secs(10), "{what} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn start(disk: &Arc, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc)> { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let w = Watcher::start_with( + Arc::clone(disk) as Arc, + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + focused, + unfocused, + )?; + Some((w, wakes)) + } + + /// The interface is woken for the first stamps of a set and for every change, and for + /// nothing else: an unchanged file polled many times is no wakeup at all. + #[test] + fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() { + let disk = Arc::new(Disk::default()); + disk.set("/a.psd", (10, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(true); + w.set_watched(["/a.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); + let looked = disk.looks.load(Ordering::SeqCst); + wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup"); + assert!(w.take().is_none()); + disk.set("/a.psd", (12, 2)); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + } + + /// Behind another app the files are stamped only at the slow interval; the window coming + /// back to the front stamps them at once. + #[test] + fn behind_another_app_the_watcher_rests_until_the_window_is_back() { + let disk = Arc::new(Disk::default()); + disk.set("/b.psd", (1, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(false); + w.set_watched(["/b.psd".to_string()].into()); + wait_for(&w, &wakes, 1); + let looked = disk.looks.load(Ordering::SeqCst); + disk.set("/b.psd", (2, 2)); + std::thread::sleep(TICK * 15); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind"); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + w.set_focused(true); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]); + } + + /// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and + /// reported whole, and dropping the watcher ends the worker. + #[test] + fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() { + let disk = Arc::new(Disk::default()); + disk.set("/c.psd", (1, 1)); + disk.set("/d.psd", (2, 2)); + let (mut w, wakes) = start(&disk, TICK, TICK).unwrap(); + w.set_focused(true); + std::thread::sleep(TICK * 10); + assert_eq!(disk.looks.load(Ordering::SeqCst), 0); + w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]); + w.set_watched(["/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd"); + w.set_watched(BTreeSet::new()); + // The worker may already be past its wait with the old set: that one round of stamps + // (one file) can still come, and nothing after it. + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 10); + assert!(disk.looks.load(Ordering::SeqCst) <= looked + 1, "nothing watched, nothing stamped"); + let result = Arc::downgrade(&w.result); + drop(w); + let t0 = std::time::Instant::now(); + while result.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running"); + std::thread::sleep(Duration::from_millis(5)); + } + } } diff --git a/crates/engine/src/footage/media_browser.rs b/crates/engine/src/footage/media_browser.rs index c0e20be71..2c40fcf20 100644 --- a/crates/engine/src/footage/media_browser.rs +++ b/crates/engine/src/footage/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -431,6 +431,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); diff --git a/crates/engine/src/footage/sequence.rs b/crates/engine/src/footage/sequence.rs index 46dca154c..fc2dab2b0 100644 --- a/crates/engine/src/footage/sequence.rs +++ b/crates/engine/src/footage/sequence.rs @@ -45,14 +45,13 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off - /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered - /// still brings its run, as it always has. + /// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports + /// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018). pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); - let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. - SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } + SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } } } diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index 656bd7708..7503178fa 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp { [440.0, 914.0], ); anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))])); - c.layers = vec![title, bar]; + c.layers = vec![title, bar].into(); c } @@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { ); anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))])); - c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor]; + c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into(); c } @@ -463,7 +463,7 @@ pub fn demo_project() -> Project { lower.out_point = t(9.5); // Stack: top first. - comp.layers = vec![lower, title, sub, line, burst, rings, null, bg]; + comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into(); for l in &mut comp.layers { if l.name == "Controller" { l.switches.video = false; diff --git a/crates/engine/src/home/templates.rs b/crates/engine/src/home/templates.rs index 500308b72..0b15baaf5 100644 --- a/crates/engine/src/home/templates.rs +++ b/crates/engine/src/home/templates.rs @@ -645,6 +645,21 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("footage").to_string() } +/// A template entry's name, made safe to write inside a folder: a template is a file like any +/// other and can name its entries anything. Nothing a platform reads as a separator, a drive or +/// the folder above survives, and a name that leaves nothing usable becomes `footage` (#2123). +fn safe_file_name(name: &str) -> String { + let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); + // `.` and `..`, and names of dots and spaces alone (Windows drops the trailing ones). + if name.trim_matches(['.', ' ']).is_empty() { "footage".to_string() } else { name } +} + +/// What two entry names must differ by to be sure of two files: a filesystem may ignore case, and +/// Win32 ignores trailing dots and spaces. +fn same_file_key(name: &str) -> String { + name.trim_end_matches(['.', ' ']).to_lowercase() +} + /// What [`embed_footage`] did. #[derive(Default)] struct Embedded { @@ -738,9 +753,21 @@ fn extract_footage(s: &Session, proj: &mut Project, entries: &[(String, Vec) let mut used = std::collections::HashSet::new(); for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) { // `media//` → one folder; a name another item took gets the item's id. - let file = base_name(k); - let item = k.trim_start_matches("media/").split('/').next().unwrap_or("0"); - let name = if used.insert(file.clone()) { file } else { format!("{item}-{file}") }; + let file = safe_file_name(&base_name(k)); + // The item's id, and only that: it is written as one ([`embed_footage`]), but a template + // from elsewhere can hold anything there, and `..\` in it walks out of `dir` on Windows. + let item = k.trim_start_matches("media/").split('/').next().filter(|i| i.bytes().all(|b| b.is_ascii_digit())).unwrap_or("0"); + // A name another entry has taken gets the item's id, and a number if that is taken too: + // the names an entry can ask for are not distinct, and two files must not become one. + // They are compared the way a filesystem might: `CLIP.png` is `clip.png` on a + // case-insensitive volume (Windows and macOS as they come), and Win32 drops a trailing + // dot or space, so `a.png.` is `a.png` there. The name written stays as it is. + let mut name = file.clone(); + let mut n = 0usize; + while !used.insert(same_file_key(&name)) { + n += 1; + name = if n == 1 { format!("{item}-{file}") } else { format!("{item}-{n}-{file}") }; + } let out = format!("{dir}/{name}"); s.services.store_file(&out, v).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?; if let Some(imp) = &s.importer { @@ -970,6 +997,57 @@ mod tests { assert_eq!(l.iter().find(|t| t.id == "lower-third").unwrap().controls, ["Name", "Title", "Bar Color"]); } + /// #2123: a template holds its footage as `media//` entries, but a template + /// from anywhere else can name them anything — and `..\\`, which only this platform reads as + /// a plain character, is a separator on Windows, so a second entry with a base name already + /// taken wrote `

/..\\..\\..\\Startup\\x-evil.bat`. Whatever an entry is called, the file + /// lands in the footage folder. + #[test] + fn extracted_footage_stays_in_its_folder() { + let s = Session::default(); + let mut proj = effectcraft_project::Project::default(); + let dir = std::env::temp_dir().join(format!("ec-templates-media-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ds = dir.to_string_lossy().into_owned(); + let entry = |k: &str| (k.to_string(), b"x".to_vec()); + let entries = [ + entry("media/0/clip.mp4"), + // The base name is taken, so this one's "item id" is used — and it is not one. + entry("media/..\\..\\..\\Startup\\x/clip.mp4"), + entry("media/1/C:evil.bat"), + // Three that collide once the first is made safe: each still gets its own file. + entry("media/1/a:b.png"), + entry("media/2/a_b.png"), + entry("media/3/2-a_b.png"), + // The same file on a case-insensitive volume, and on Windows, which drops the dot. + entry("media/4/clip.png"), + entry("media/5/CLIP.png"), + entry("media/6/a.png"), + entry("media/7/a.png."), + ]; + let written = extract_footage(&s, &mut proj, &entries, &ds).unwrap(); + assert_eq!(written.len(), entries.len()); + for w in &written { + let name = w.strip_prefix(&format!("{ds}/")).unwrap_or_else(|| panic!("{w} is outside {ds}")); + assert!(!name.contains(['/', '\\', ':']), "{w}"); + assert!(!name.trim_matches(['.', ' ']).is_empty(), "{w}"); + } + // One file each, all of them in the folder, none overwritten by the next. + assert_eq!(written.iter().collect::>().len(), entries.len(), "{written:?}"); + assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), entries.len()); + let _ = std::fs::remove_dir_all(&dir); + // And the names that would leave nothing to write. + for n in ["..", ".", " .. ", "...", ""] { + assert_eq!(safe_file_name(n), "footage", "{n:?}"); + } + assert_eq!(safe_file_name("clip.mp4"), "clip.mp4"); + assert_eq!(safe_file_name(".hidden.mp4"), ".hidden.mp4"); + assert_eq!(safe_file_name("a/b\\c:d"), "a_b_c_d"); + // Names a filesystem may not tell apart (only the case half of this shows on macOS). + assert_eq!(same_file_key("A.PNG. "), same_file_key("a.png")); + assert_ne!(same_file_key("a.png"), same_file_key("b.png")); + } + #[test] fn create_opens_an_untitled_copy() { let mut s = Session::default(); diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index ec4cff579..3b166b83e 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -1,3 +1,5 @@ +pub(crate) mod keyframe_text; +pub(crate) mod keyframe_text_effects; pub mod psd_import; pub mod vector; pub mod xml_project; diff --git a/crates/engine/src/interchange/keyframe_text.rs b/crates/engine/src/interchange/keyframe_text.rs new file mode 100644 index 000000000..5f8ba9441 --- /dev/null +++ b/crates/engine/src/interchange/keyframe_text.rs @@ -0,0 +1,348 @@ +//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other +//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After +//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame +//! number in comp time, then the value) and static ones as a single row with no frame. Easing +//! and tangents aren't part of the format; EffectCraft's own paste keeps them. + +use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; + +use crate::{KeyRef, keyframe_text_effects}; + +/// Transform properties with the unit their columns are labelled with and, for vectors, the +/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit. +const TRANSFORM: &[(&str, &str, Option)] = &[ + ("poi", "pixels", Some(0.0)), + ("anchor", "pixels", Some(0.0)), + ("position", "pixels", Some(0.0)), + ("positionX", "", None), + ("positionY", "", None), + ("positionZ", "", None), + ("scale", "percent", Some(100.0)), + ("orientation", "degrees", None), + ("rotationX", "degrees", None), + ("rotationY", "degrees", None), + ("rotation", "degrees", None), + ("opacity", "percent", None), +]; + +/// How a copied property is written: its block's header line, column labels and values. +struct Channel { + header: String, + labels: Vec, + values: Values, +} + +/// How a property's value becomes the numbers in a row. +#[derive(Clone, Copy)] +enum Values { + Plain, + /// A 2D layer's Transform vector: the third component is the one the layer shows (it may + /// keep another from pasted 3D keys). + Flat(f64), + /// RGBA 0–1 as alpha, red, green, blue 0–255. + Argb, + /// A popup's 0-based choice as After Effects' 1-based one. + OneBased, + Scale(f64), +} + +impl Values { + fn row(self, mut v: Vec) -> Vec { + match (self, v.as_slice()) { + (Values::Flat(z), _) => { + if let Some(c) = v.get_mut(2) { + *c = z; + } + v + } + (Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(), + (Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(), + (Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(), + _ => v, + } + } +} + +/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come +/// from one layer and at least one property can be written. +pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { + let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; + if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { + return None; + } + let layer = comp.layer(lid)?; + let fps = comp.frame_rate.as_f64(); + let mut blocks = String::new(); + layer.props.walk("", &mut |_, pr| { + let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + let rows: Vec<(String, Vec)> = if !times.is_empty() { + let frame = |t| num(layer.comp_time(t).seconds() * fps); + times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect() + } else if statics.iter().any(|s| s.1 == pr.uid) { + vec![(String::new(), pr.value.components())] + } else { + return; + }; + let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return }; + if rows.iter().any(|(_, v)| v.is_empty()) { + return; + } + blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t"))); + for (frame, values) in rows { + let values: Vec = ch.values.row(values).into_iter().map(num).collect(); + blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); + } + blocks.push('\n'); + }); + if blocks.is_empty() { + return None; + } + // After Effects writes a text layer's source as 1 × 1. Other layers without a source of + // their own (shapes, cameras) take the comp's size, and all of them its pixel aspect. + let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) { + (LayerSource::Text, _) => (1, 1), + (_, (0, 0)) => (comp.width, comp.height), + (_, size) => size, + }; + let par = match layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.pixel_aspect, + Some(ItemKind::Solid(s)) => s.pixel_aspect, + Some(ItemKind::Comp(c)) => c.pixel_aspect, + _ => comp.pixel_aspect, + }, + _ => comp.pixel_aspect, + }; + // The layout of current After Effects: a `Layer` line before the layer's properties (parsers + // such as ae-keyframes reject properties without one), and a tab after every column. + Some(format!( + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n", + num(fps), + num(par), + num(comp.pixel_aspect) + )) +} + +/// How the layer's property `pr` is written, by where it sits in the layer: `None` for +/// properties the text doesn't carry. +fn channel(layer: &Layer, pr: &Property) -> Option { + let chain = layer.props.node_chain(pr.uid)?; + match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) { + None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), + Some("transform") => transform(layer, pr), + Some("effects") => effect(&chain, pr), + Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr), + _ => None, + } +} + +/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind +/// and place (`Mask #2`, `Group #1`), not by name. +const INSTANCES: &[(&str, &str)] = &[ + ("mask", "Mask"), + ("animator", "Animator"), + ("rangeSelector", "Range Selector"), + ("wigglySelector", "Wiggly Selector"), + ("expressionSelector", "Expression Selector"), + ("group", "Group"), + ("rect", "Rectangle Path"), + ("ellipse", "Ellipse Path"), + ("star", "Polystar Path"), + ("path", "Path"), + ("fill", "Fill"), + ("stroke", "Stroke"), + ("gfill", "Gradient Fill"), + ("gstroke", "Gradient Stroke"), + ("trim", "Trim Paths"), + ("repeater", "Repeater"), + ("round", "Round Corners"), + ("offset", "Offset Paths"), + ("pucker", "Pucker & Bloat"), + ("twist", "Twist"), + ("zigzag", "Zig Zag"), + ("wiggle", "Wiggle Paths"), + ("merge", "Merge Paths"), +]; + +/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's +/// name, then each group down to the property. Instances go by kind and place, and a text +/// selector's own properties carry their place too (`Start #1`). +fn nested(chain: &[&Node], pr: &Property) -> Option { + let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); + let parent = groups.last()?; + let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?; + let mut header = groups.first()?.name.clone(); + for pair in groups.windows(2) { + let [outer, g] = pair else { continue }; + match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) { + Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)), + None => header.push_str(&format!("\t{}", g.name)), + } + } + if parent.match_id.ends_with("Selector") { + header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?)); + } else { + header.push_str(&format!("\t{}", pr.name)); + } + Some(Channel { header, labels, values }) +} + +/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, +/// then the property's name and After Effects index. Properties in twirl-down groups are +/// written without their group, as After Effects does. +fn effect(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; + let GroupKind::Effect { effect } = &fx.kind else { return None }; + let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); + let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect(); + let (labels, values) = columns(effect, &path, pr, true)?; + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?); + Some(Channel { header, labels, values }) +} + +/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a +/// twirl-down group takes an index where it opens and another where it closes. The hidden +/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects +/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it. +fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option { + fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool { + for c in &g.children { + match c { + Node::Prop(p) if is_layer_source(g, p) => {} + Node::Prop(p) => { + let path = format!("{prefix}{}", p.match_id); + *n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2); + if p.uid == uid { + return true; + } + } + Node::Group(sub) => { + *n += 1; + if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) { + return true; + } + *n += 1; + } + } + } + false + } + let mut n = 1; + count(effect, fx, "", uid, &mut n).then_some(n) +} + +fn is_layer_source(g: &PropGroup, p: &Property) -> bool { + p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id) +} + +/// 1-based position of a child in its group. +fn place(group: &PropGroup, uid: Uid) -> Option { + group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) +} + +/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]). +#[derive(Clone, Copy)] +enum Exception { + /// Not written. + Skip, + Unit(&'static str), + Values(Values), +} + +/// Properties After Effects writes differently from what their editor suggests, by the effect +/// or group they're directly in and their match id. +const EXCEPTIONS: &[(&str, &str, Exception)] = &[ + // Path Options' Path picks a mask, which After Effects doesn't write. + ("pathOptions", "path", Exception::Skip), + ("mask", "feather", Exception::Unit("pixels")), + ("mask", "expansion", Exception::Unit("pixels")), + ("cameraOptions", "zoom", Exception::Unit("pixels")), + ("cameraOptions", "focusDistance", Exception::Unit("pixels")), + ("cameraOptions", "aperture", Exception::Unit("pixels")), + ("cameraOptions", "highlightGain", Exception::Unit("")), + ("cameraOptions", "highlightSaturation", Exception::Unit("")), + // After Effects keeps the threshold as 0–1. + ("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))), + ("lightOptions", "shadowDiffusion", Exception::Unit("pixels")), + // Off, On, Only: written from 0. + ("materialOptions", "castsShadows", Exception::Values(Values::Plain)), + ("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)), +]; + +/// Column labels and values for a property in `scope`, the effect (by id) or group (by match +/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`] +/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise. +fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr, in_effect)?; + let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2); + let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2)); + Some(match exception.or_else(effect_unit) { + None => (by_editor, values), + Some(Exception::Skip) => return None, + Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values), + Some(Exception::Values(values)) => (by_editor, values), + }) +} + +/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100 +/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts), +/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists +/// the effect parameters After Effects labels otherwise. +fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let dims = pr.value.dims(); + Some(match &pr.ui { + ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), + ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), + ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain), + ParamUi::Percent => (labels("percent", dims), Values::Plain), + ParamUi::Angle => (labels("degrees", dims), Values::Plain), + ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), + ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain), + _ => return None, + }) +} + +fn transform(layer: &Layer, pr: &Property) -> Option { + let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { + return None; + } + // After Effects labels only the first of Orientation's three columns, and names Z Rotation + // plain Rotation (as AE2Blend expects). + let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) }; + let values = match z { + Some(z) if !layer.is_3d() => Values::Flat(*z), + _ => Values::Plain, + }; + Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) +} + +/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After +/// Effects writes a shape's Size). +fn labels(unit: &str, dims: usize) -> Vec { + match dims { + 0 => vec![], + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), + } +} + +/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at +/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z +/// Position. +fn active(layer: &Layer, id: &str) -> bool { + match id { + "poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest, + "position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()), + _ => true, + } +} + +/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`). +fn num(v: f64) -> String { + let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} diff --git a/crates/engine/src/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs new file mode 100644 index 000000000..83f5b6c20 --- /dev/null +++ b/crates/engine/src/interchange/keyframe_text_effects.rs @@ -0,0 +1,386 @@ +//! After Effects' index and unit for effect parameters where they differ from what +//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text. +//! After Effects keeps an effect's parameter indices for good (projects store them), so they +//! hold for later versions. Parameters After Effects lacks, or names differently and keeps +//! elsewhere, aren't listed. + +/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After +/// Effects' index for it; the parameters after it count on from there. After Effects numbers +/// legacy parameters it hides, so its indices skip ahead. +pub(crate) const INDICES: &[(&str, &str, usize)] = &[ + ("ec.3d.channelextract", "clampOutput", 6), + ("ec.3d.channelextract", "antialias", 5), + ("ec.3d.extractor", "blackPoint", 4), + ("ec.audio.parametriceq", "band1Enable", 3), + ("ec.blur.radial", "amount", 3), + ("ec.color.cccolorneutralizer", "midtonesUnbalance", 8), + ("ec.color.cccolorneutralizer", "highlightsUnbalance", 14), + ("ec.color.cccolorneutralizer", "pinning", 20), + ("ec.color.cckernel", "k1", 3), + ("ec.color.cckernel", "k4", 8), + ("ec.color.cckernel", "k7", 13), + ("ec.color.cckernel", "scale", 17), + ("ec.color.cckernel", "absolute", 18), + ("ec.color.colorstabilizer", "stabilize", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.levels", "inBlack", 4), + ("ec.color.levelsic", "rgb/rgbInBlack", 5), + ("ec.color.lumetri", "highDynamicRange", 3), + ("ec.color.lumetri", "basicCorrection/inputLut", 8), + ("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12), + ("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22), + ("ec.color.lumetri", "basicCorrection/saturation", 26), + ("ec.color.lumetri", "creative/look", 32), + ("ec.color.lumetri", "creative/lookIntensity", 33), + ("ec.color.lumetri", "creative/adjustments/tintBalance", 40), + ("ec.color.lumetri", "hslSecondary/key/showMask", 83), + ("ec.color.lumetri", "hslSecondary/refine/denoise", 90), + ("ec.color.lumetri", "hslSecondary/correction/temperature", 96), + ("ec.color.lumetri", "vignette/vignetteAmount", 105), + ("ec.color.selectivecolor", "details/reds/redsCyan", 10), + ("ec.color.videolimiter", "compressionBeforeClipping", 3), + ("ec.distort.ccflomotion", "knot1", 3), + ("ec.distort.ccpowerpin", "expandTop", 9), + ("ec.distort.upscale", "scale", 4), + ("ec.distort.puppet", "refinement", 3), + ("ec.control.stereo3d", "configuration", 3), + ("ec.control.stereo3d", "convergenceOf", 8), + ("ec.generate.advancedlightning", "turbulence", 17), + ("ec.generate.advancedlightning", "alphaObstacle", 16), + ("ec.generate.advancedlightning", "compositeOnOriginal", 21), + ("ec.generate.ccgluegun", "style", 9), + ("ec.generate.cclightburst", "setColor", 7), + ("ec.generate.cclightrays", "colorFromSource", 8), + ("ec.generate.cclightrays", "color", 10), + ("ec.generate.circle", "feather/feather", 7), + ("ec.generate.paintbucket", "color", 9), + ("ec.vr.blur", "frameLayout", 3), + ("ec.vr.blur", "blurriness", 6), + ("ec.vr.chromaticaberrations", "frameLayout", 3), + ("ec.vr.chromaticaberrations", "aberrationRed", 7), + ("ec.vr.colorgradients", "frameLayout", 3), + ("ec.vr.colorgradients", "point1", 10), + ("ec.vr.colorgradients", "point2", 12), + ("ec.vr.colorgradients", "point3", 14), + ("ec.vr.colorgradients", "point4", 16), + ("ec.vr.colorgradients", "point5", 18), + ("ec.vr.colorgradients", "blend", 7), + ("ec.vr.colorgradients", "opacity", 27), + ("ec.vr.denoise", "frameLayout", 3), + ("ec.vr.denoise", "noiseType", 6), + ("ec.vr.digitalglitch", "frameLayout", 3), + ("ec.vr.digitalglitch", "masterAmplitude", 9), + ("ec.vr.digitalglitch", "distortionRate", 16), + ("ec.vr.digitalglitch", "distortionComplexity", 15), + ("ec.vr.digitalglitch", "distortionEvolution", 17), + ("ec.vr.digitalglitch", "colorDistortion", 11), + ("ec.vr.digitalglitch", "randomSeed", 39), + ("ec.vr.fractalnoise", "frameLayout", 3), + ("ec.vr.fractalnoise", "fractalType", 6), + ("ec.vr.fractalnoise", "randomSeed", 25), + ("ec.vr.glow", "frameLayout", 3), + ("ec.vr.glow", "luminanceThreshold", 6), + ("ec.vr.planetosphere", "frameLayout", 3), + ("ec.vr.planetosphere", "scale", 6), + ("ec.vr.planetosphere", "feather", 8), + ("ec.vr.planetosphere", "tilt", 15), + ("ec.vr.rotatesphere", "frameLayout", 3), + ("ec.vr.rotatesphere", "invertRotation", 9), + ("ec.vr.rotatesphere", "tilt", 6), + ("ec.vr.sharpen", "frameLayout", 3), + ("ec.vr.sharpen", "sharpenAmount", 6), + ("ec.key.colordifference", "view", 3), + ("ec.key.colorrange", "fuzziness", 3), + ("ec.key.extract", "channel", 3), + ("ec.key.linearcolor", "view", 3), + ("ec.matte.refinehard", "feather", 2), + ("ec.matte.refinehard", "reduceChatter", 5), + ("ec.matte.refinehard", "decontaminationAmount", 14), + ("ec.matte.refinesoft", "reduceChatter", 10), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", 13), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22), + ("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27), + ("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29), + ("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8), + ("ec.obsolete.basictext", "position", 2), + ("ec.key.spill", "suppression", 3), + ("ec.perspective.cameratracker", "shotType", 4), + ("ec.perspective.cameratracker", "advanced/solveMethod", 12), + ("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17), + ("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18), + ("ec.perspective.cccylinder", "rotationX", 9), + ("ec.perspective.cccylinder", "render", 13), + ("ec.perspective.cccylinder", "lightIntensity", 15), + ("ec.perspective.cccylinder", "ambient", 21), + ("ec.sim.ccdrizzle", "light/lightIntensity", 10), + ("ec.sim.ccdrizzle", "light/lightHeight", 13), + ("ec.sim.ccdrizzle", "light/lightDirection", 15), + ("ec.sim.ccmrmercury", "blobBirthSize", 15), + ("ec.sim.ccmrmercury", "light/lightIntensity", 19), + ("ec.sim.ccmrmercury", "light/lightHeight", 22), + ("ec.sim.ccmrmercury", "light/lightDirection", 24), + ("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24), + ("ec.sim.ccparticlesystems2", "particle/birthColor", 27), + ("ec.sim.ccrainfall", "transferMode", 16), + ("ec.sim.ccrainfall", "extras/randomSeed", 23), + ("ec.sim.ccsnowfall", "color", 19), + ("ec.sim.ccsnowfall", "transferMode", 26), + ("ec.sim.ccsnowfall", "extras/randomSeed", 32), + ("ec.stylize.ccblockload", "startCleared", 4), + ("ec.stylize.cchextile", "rotate", 6), + ("ec.stylize.glow", "colorLooping", 9), + ("ec.text.timecode", "timeUnits", 5), + ("ec.text.timecode", "textPosition", 9), + ("ec.utility.facemeasurements", "headOrientationX", 9), + ("ec.utility.facemeasurements", "rightEyebrowRaise", 20), + ("ec.utility.facemeasurements", "mouthOffsetX", 26), + ("ec.utility.facetrackpoints", "leftEyebrowInner", 10), + ("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12), + ("ec.utility.facetrackpoints", "leftEyebrowOuter", 14), + ("ec.utility.facetrackpoints", "rightEyebrowInner", 24), + ("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26), + ("ec.utility.facetrackpoints", "rightEyebrowOuter", 28), + ("ec.utility.facetrackpoints", "leftEyeInner", 15), + ("ec.utility.facetrackpoints", "leftEyeOuter", 21), + ("ec.utility.facetrackpoints", "leftPupil", 20), + ("ec.utility.facetrackpoints", "rightEyeInner", 29), + ("ec.utility.facetrackpoints", "rightEyeOuter", 35), + ("ec.utility.facetrackpoints", "rightPupil", 34), + ("ec.utility.facetrackpoints", "noseTip", 41), + ("ec.utility.facetrackpoints", "leftNostril", 44), + ("ec.utility.facetrackpoints", "rightNostril", 46), + ("ec.utility.facetrackpoints", "mouthLeft", 49), + ("ec.utility.facetrackpoints", "mouthRight", 55), + ("ec.utility.facetrackpoints", "mouthTop", 52), + ("ec.utility.facetrackpoints", "mouthBottom", 58), + ("ec.utility.facetrackpoints", "chin", 84), +]; + +/// Effect id, parameter path and the unit After Effects labels the parameter's columns with. +pub(crate) const UNITS: &[(&str, &str, &str)] = &[ + ("ec.3d.depthoffield", "focalBias", ""), + ("ec.3d.idmatte", "feather", ""), + ("ec.audio.flangechorus", "voiceSeparationTime", ""), + ("ec.audio.flangechorus", "voicePhaseChange", ""), + ("ec.audio.modulator", "modulationRate", ""), + ("ec.audio.parametriceq", "band1BoostCut", "percent"), + ("ec.audio.parametriceq", "band2BoostCut", "percent"), + ("ec.audio.parametriceq", "band3BoostCut", "percent"), + ("ec.audio.stereomixer", "leftLevel", "percent"), + ("ec.audio.stereomixer", "rightLevel", "percent"), + ("ec.audio.stereomixer", "leftPan", "percent"), + ("ec.audio.stereomixer", "rightPan", "percent"), + ("ec.blur.bilateral", "threshold", ""), + ("ec.blur.ccradial", "amount", "pixels"), + ("ec.blur.ccradialfast", "amount", ""), + ("ec.blur.camerashakedeblur", "strength", "percent"), + ("ec.blur.radial", "amount", ""), + ("ec.blur.smart", "radius", ""), + ("ec.blur.smart", "threshold", ""), + ("ec.color.autocolor", "blackClip", "percent"), + ("ec.color.autocolor", "whiteClip", "percent"), + ("ec.color.autocontrast", "blackClip", "percent"), + ("ec.color.autocontrast", "whiteClip", "percent"), + ("ec.color.autolevels", "blackClip", "percent"), + ("ec.color.autolevels", "whiteClip", "percent"), + ("ec.color.brightnesscontrast", "contrast", "pixels"), + ("ec.color.cccolorneutralizer", "pinning", ""), + ("ec.color.cccolorneutralizer", "blendWOriginal", ""), + ("ec.color.changecolor", "lightnessTransform", "pixels"), + ("ec.color.changecolor", "saturationTransform", "pixels"), + ("ec.color.colorbalance", "shadowRed", "pixels"), + ("ec.color.colorbalance", "shadowGreen", "pixels"), + ("ec.color.colorbalance", "shadowBlue", "pixels"), + ("ec.color.colorbalance", "midRed", "pixels"), + ("ec.color.colorbalance", "midGreen", "pixels"), + ("ec.color.colorbalance", "midBlue", "pixels"), + ("ec.color.colorbalance", "hiRed", "pixels"), + ("ec.color.colorbalance", "hiGreen", "pixels"), + ("ec.color.colorbalance", "hiBlue", "pixels"), + ("ec.color.colorbalancehls", "lightness", "pixels"), + ("ec.color.colorbalancehls", "saturation", "pixels"), + ("ec.color.colorstabilizer", "sampleSize", "percent"), + ("ec.color.gammapedestalgain", "blackStretch", "percent"), + ("ec.color.huesaturation", "saturation", "pixels"), + ("ec.color.huesaturation", "lightness", "pixels"), + ("ec.color.huesaturation", "colorizeSaturation", ""), + ("ec.color.huesaturation", "colorizeLightness", "pixels"), + ("ec.color.lumetri", "creative/adjustments/fadedFilm", ""), + ("ec.color.lumetri", "hslSecondary/refine/denoise", ""), + ("ec.color.lumetri", "hslSecondary/refine/blur", ""), + ("ec.color.lumetri", "vignette/vignetteMidpoint", ""), + ("ec.color.lumetri", "vignette/vignetteFeather", ""), + ("ec.color.psarbitrarymap", "phase", "degrees"), + ("ec.color.selectivecolor", "details/reds/redsCyan", "percent"), + ("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"), + ("ec.color.selectivecolor", "details/reds/redsYellow", "percent"), + ("ec.color.selectivecolor", "details/reds/redsBlack", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"), + ("ec.color.selectivecolor", "details/greens/greensCyan", "percent"), + ("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"), + ("ec.color.selectivecolor", "details/greens/greensYellow", "percent"), + ("ec.color.selectivecolor", "details/greens/greensBlack", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"), + ("ec.color.shadowhighlight", "shadowAmount", ""), + ("ec.color.shadowhighlight", "highlightAmount", ""), + ("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"), + ("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"), + ("ec.distort.bulge", "height", "pixels"), + ("ec.distort.bulge", "taperRadius", ""), + ("ec.distort.ccbendit", "bend", "pixels"), + ("ec.distort.ccbender", "amount", "pixels"), + ("ec.distort.ccgriddler", "tileSize", "percent"), + ("ec.distort.cctiler", "scale", "percent"), + ("ec.distort.upscale", "scale", "percent"), + ("ec.distort.magnify", "magnification", "pixels"), + ("ec.distort.magnify", "size", "percent"), + ("ec.distort.puppet", "refinement", ""), + ("ec.distort.ripple", "radius", ""), + ("ec.distort.ripple", "speed", "percent"), + ("ec.distort.transform", "opacity", ""), + ("ec.distort.turbulentdisplace", "complexity", "percent"), + ("ec.distort.twirl", "radius", ""), + ("ec.distort.wavewarp", "width", "percent"), + ("ec.distort.wavewarp", "speed", "pixels"), + ("ec.generate.fourcolor", "blend", "percent"), + ("ec.generate.advancedlightning", "coreSettings/coreRadius", ""), + ("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"), + ("ec.generate.advancedlightning", "alphaObstacle", "pixels"), + ("ec.generate.ccgluegun", "reflection", ""), + ("ec.generate.cclightburst", "rayLength", ""), + ("ec.generate.cclightsweep", "edgeThickness", ""), + ("ec.generate.ccthreads", "coverage", ""), + ("ec.generate.ccthreads", "shadowing", ""), + ("ec.generate.ccthreads", "texture", ""), + ("ec.generate.checkerboard", "width", "percent"), + ("ec.generate.checkerboard", "height", "percent"), + ("ec.generate.grid", "width", "percent"), + ("ec.generate.grid", "height", "percent"), + ("ec.generate.lensflare", "flareBrightness", "percent"), + ("ec.vr.chromaticaberrations", "falloff", ""), + ("ec.vr.denoise", "noiseLevel", ""), + ("ec.vr.digitalglitch", "colorDistortion", ""), + ("ec.vr.fractalnoise", "subSettings/subInfluence", ""), + ("ec.vr.glow", "luminanceThreshold", ""), + ("ec.vr.planetosphere", "feather", ""), + ("ec.vr.sharpen", "sharpenAmount", ""), + ("ec.key.advancedspill", "suppression", "percent"), + ("ec.key.advancedspill", "ultraSettings/tolerance", ""), + ("ec.key.advancedspill", "ultraSettings/desaturate", ""), + ("ec.key.advancedspill", "ultraSettings/spillRange", ""), + ("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""), + ("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""), + ("ec.key.ccsimplewireremoval", "thickness", ""), + ("ec.key.keycleaner", "additionalEdgeRadius", ""), + ("ec.key.keycleaner", "alphaContrast", "percent"), + ("ec.matte.refinehard", "feather", ""), + ("ec.matte.refinehard", "increaseDecontaminationRadius", ""), + ("ec.matte.refinesoft", "smooth", ""), + ("ec.matte.refinesoft", "feather", ""), + ("ec.matte.refinesoft", "shiftEdge", "percent"), + ("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/feather", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.simplechoker", "chokeMatte", "pixels"), + ("ec.noise.fractal", "transform/scale", "percent"), + ("ec.noise.fractal", "transform/scaleWidth", "percent"), + ("ec.noise.fractal", "transform/scaleHeight", "percent"), + ("ec.noise.fractal", "complexity", "percent"), + ("ec.noise.fractal", "subSettings/subInfluence", ""), + ("ec.noise.turbulent", "transform/scale", "percent"), + ("ec.noise.turbulent", "transform/scaleWidth", "percent"), + ("ec.noise.turbulent", "transform/scaleHeight", "percent"), + ("ec.noise.turbulent", "complexity", "percent"), + ("ec.noise.turbulent", "subSettings/subInfluence", ""), + ("ec.key.colorkey", "edgeFeather", ""), + ("ec.obsolete.lightning", "branchAngle", ""), + ("ec.key.luma", "edgeFeather", ""), + ("ec.blur.reduceflicker", "softness", ""), + ("ec.perspective.cameratracker", "trackPointSize", "percent"), + ("ec.perspective.cameratracker", "targetSize", "percent"), + ("ec.perspective.cccylinder", "lightHeight", "pixels"), + ("ec.sim.ccbubbles", "bubbleSize", "percent"), + ("ec.sim.ccdrizzle", "displacement", ""), + ("ec.sim.ccdrizzle", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "longevity", ""), + ("ec.sim.ccmrmercury", "blobInfluence", "percent"), + ("ec.sim.ccmrmercury", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "shading/metal", ""), + ("ec.sim.ccparticlesystems2", "birthRate", ""), + ("ec.sim.ccparticlesystems2", "longevity", ""), + ("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"), + ("ec.sim.ccrainfall", "windVariation", ""), + ("ec.sim.ccrainfall", "opacity", ""), + ("ec.sim.ccsnowfall", "sizeVariation", ""), + ("ec.sim.ccsnowfall", "speedVariation", ""), + ("ec.sim.ccsnowfall", "windVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""), + ("ec.sim.ccsnowfall", "opacity", ""), + ("ec.sim.ccstarburst", "speed", "percent"), + ("ec.sim.ccstarburst", "size", "pixels"), + ("ec.stylize.ccblockload", "completion", ""), + ("ec.stylize.ccburnfilm", "burn", ""), + ("ec.stylize.cchextile", "smearing", ""), + ("ec.stylize.cckaleida", "size", "percent"), + ("ec.stylize.ccvignette", "pinHighlights", ""), + ("ec.stylize.cartoon", "detailThreshold", ""), + ("ec.stylize.cartoon", "fill/shadingSmoothness", ""), + ("ec.stylize.cartoon", "edge/edgeSoftness", ""), + ("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""), + ("ec.stylize.glow", "colorLoops", "percent"), + ("ec.stylize.roughenedges", "stretch", "pixels"), + ("ec.time.pixelmotionblur", "vectorDetail", ""), + ("ec.transition.blockdissolve", "blockWidth", "percent"), + ("ec.transition.blockdissolve", "blockHeight", "percent"), + ("ec.transition.ccgridwipe", "border", "pixels"), + ("ec.transition.ccgridwipe", "tiles", "percent"), + ("ec.transition.cclinesweep", "completion", ""), + ("ec.transition.ccscalewipe", "stretch", "pixels"), + ("ec.transition.venetian", "width", "percent"), + ("ec.transition.venetian", "feather", ""), + ("ec.utility.cineon", "highlightRolloff", ""), + ("ec.utility.facemeasurements", "headOrientationX", "degrees"), + ("ec.utility.facemeasurements", "headOrientationY", "degrees"), + ("ec.utility.facemeasurements", "headOrientationZ", "degrees"), + ("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetX", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetY", "percent"), +]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7be59c277..a7484a3f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,7 @@ pub use home::templates; pub use interchange::psd_import; pub use interchange::vector; pub use interchange::xml_project; +use interchange::{keyframe_text, keyframe_text_effects}; pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; pub use settings::autosave; pub use settings::config; @@ -240,8 +241,6 @@ pub struct Session { /// 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, - /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). - pub footage_scan: Option, /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one @@ -350,7 +349,6 @@ impl Default for Session { stored_paths: Default::default(), footage_stamps: Default::default(), disk_salt: None, - footage_scan: None, script_calls: ScriptCalls::default(), } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index df07c633b..a7418ae58 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option { /// Recent Script Files), `history` (undo steps, newest /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and -/// `scriptPanels` (ScriptUI panels). +/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here, +/// and [`dynamic_nodes`] puts it in the folder's submenu). pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { use serde_json::json; let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec { @@ -167,6 +168,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option< } } +/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder +/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸ +/// Animate.jsx`), placed where the folder sorts among the other entries. +pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { + let (entries, empty) = dynamic(s, name, cx); + let scripts = matches!(name, "scripts" | "scriptPanels"); + let mut out: Vec = vec![]; + for mut e in entries { + let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else { + out.push(MenuNode::Item(e)); + continue; + }; + e.label = file; + let open = out.iter_mut().find_map(|n| match n { + MenuNode::Submenu { label, children } if *label == folder => Some(children), + _ => None, + }); + match open { + Some(children) => children.push(MenuNode::Item(e)), + None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }), + } + } + (out, empty) +} + /// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or /// workspace, like After Effects). pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String { @@ -1010,6 +1036,8 @@ Animation --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold + Mute Keyframes | keys.setMuted + Unmute Keyframes | keys.setMuted {"muted":false} Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 27e9a7dce..d8f8cf7b5 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -208,6 +208,16 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 { let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 }; ((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64 } + // An 18-byte header and 24-bit BGR, 32-bit BGRA or 8-bit grey (Alpha) pixels, uncompressed. + OutputFormat::TargaSequence => { + use effectcraft_project::render_queue::Channels; + let bpp = match om.channels { + Channels::Rgb => 3, + Channels::Rgba => 4, + Channels::Alpha => 1, + }; + (18 + w as u64 * h as u64 * bpp) as f64 + } OutputFormat::WebM => mbps(8.0) * px / fps, OutputFormat::Gif => w as f64 * h as f64 * 0.5, OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps, @@ -676,7 +686,7 @@ impl Session { continue; } if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { + for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { l.source = LayerSource::Footage { item: fid }; } } diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 4c39cc89e..d6bb96831 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -116,6 +116,25 @@ fn persists_in_project_file() { assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty()); } +#[test] +fn targa_sequence_output_module() { + let (mut s, _) = rq_session(); + let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap(); + let om = &s.project.render_queue[0].output; + assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha")); + assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}"); + // Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without. + assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0); + s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap(); + assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0); + let f = s.execute("renderQueue.formats", json!({})).unwrap(); + let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned(); + assert_eq!( + tga, + Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true})) + ); +} + #[test] fn failure_toast_names_the_first_reason() { struct Failing; @@ -186,7 +205,7 @@ fn without_exporter() { s.execute("renderQueue.add", json!({})).unwrap(); assert!(!s.is_enabled("renderQueue.render")); let f = s.execute("renderQueue.formats", json!({})).unwrap(); - assert_eq!(f["formats"].as_array().unwrap().len(), 13); + assert_eq!(f["formats"].as_array().unwrap().len(), 14); assert_eq!(f["formats"][0]["available"], false); } diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d218a7bb..2c211e77a 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -402,12 +402,16 @@ pub struct ScriptUi { #[serde(skip)] pub tasks: Option, /// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating - /// tasks of scripts without an open window stop then. + /// tasks of scripts without an open window stop then, except those of Startup scripts. #[serde(skip)] pub project_epoch: u64, /// The script contexts of this session with scheduled tasks (kept by the scripting engine). #[serde(skip)] pub task_hosts: Vec, + /// The script contexts of Startup scripts: their tasks go on when the project is closed or + /// replaced, until EffectCraft quits. + #[serde(skip)] + pub startup_hosts: Vec, } impl ScriptUi { @@ -755,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { "screenX": num("screenX", -FAR, FAR, 0.0), "screenY": num("screenY", -FAR, FAR, 0.0), "button": num("button", 0.0, 2.0, 0.0).round(), + "buttons": num("buttons", 0.0, 7.0, 0.0).round(), "detail": num("detail", 0.0, 1000.0, clicks).round(), "altKey": flag("altKey"), "ctrlKey": flag("ctrlKey"), @@ -764,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value }) } +/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the +/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`. +pub(crate) fn resize(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.resize"; + let window = window_p(s, p, c)?; + let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?; + let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0)); + let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) }; + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) }) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index adbd3ab81..2d425a4e6 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -50,8 +50,20 @@ pub trait Services: Send + Sync { pub struct FsServices; impl Services for FsServices { + /// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading + /// those never ends (#2134). fn read_file(&self, path: &str) -> std::io::Result> { - std::fs::read(path) + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file"))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { config::atomic_write(std::path::Path::new(path), data) @@ -70,3 +82,21 @@ pub trait Importer: Send + Sync { /// media layer reads from memory (the browser) register them; file-based ones need nothing. fn add_frames(&self, _paths: &[String]) {} } + +#[cfg(test)] +mod tests { + use super::{FsServices, Services}; + + /// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error. + #[test] + fn fs_services_read_only_regular_files() { + let dir = std::env::temp_dir(); + assert!(FsServices.read_file(&dir.to_string_lossy()).is_err()); + #[cfg(unix)] + assert!(FsServices.read_file("/dev/zero").is_err()); + let f = dir.join(format!("ec-services-read-{}.txt", std::process::id())); + std::fs::write(&f, b"data").unwrap(); + assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 6f2d273ad..86127a510 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -96,7 +96,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/settings/config.rs b/crates/engine/src/settings/config.rs index 00e7377b7..17d7c940a 100644 --- a/crates/engine/src/settings/config.rs +++ b/crates/engine/src/settings/config.rs @@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync { } Ok(text) } + /// Write entry `name`, replacing any earlier text. fn write(&self, name: &str, data: &str) -> std::io::Result<()>; + /// Remove entry `name`; a missing entry is no error. fn remove(&self, name: &str) -> std::io::Result<()>; /// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`), /// sorted. Stores that can't list return nothing. @@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync { let _ = dir; vec![] } + /// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`]. + fn list_dirs(&self, dir: &str) -> Vec { + let _ = dir; + vec![] + } /// The folder next to the configuration (default Auto-Save folder for untitled projects), /// in the namespace of [`ConfigStore::files`]; `None` when there is none. fn dir(&self) -> Option { @@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig { let Ok(m) = self.files.lock() else { return vec![] }; m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect() } + /// The first name part of the keys under `dir/`. + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{}/", dir.trim_end_matches('/')); + let Ok(m) = self.files.lock() else { return vec![] }; + let mut v: Vec = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect(); + v.sort(); + v.dedup(); + v + } } /// Files in one directory, written atomically. @@ -153,6 +169,13 @@ impl ConfigStore for DirConfig { v.sort(); v } + /// The folders in `dir`, following links like [`ConfigStore::list`] does for files. + fn list_dirs(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] }; + let mut v: Vec = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + } fn remove(&self, name: &str) -> std::io::Result<()> { match std::fs::remove_file(self.dir.join(name)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), diff --git a/crates/engine/src/settings/prefs/choices.rs b/crates/engine/src/settings/prefs/choices.rs index 4145e0161..8935507a6 100644 --- a/crates/engine/src/settings/prefs/choices.rs +++ b/crates/engine/src/settings/prefs/choices.rs @@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Deutsch", "de"), ("Español", "es"), ("Français", "fr"), + ("Polski", "pl"), ("Português (Brasil)", "pt-br"), ("Русский", "ru"), ("Українська", "uk"), diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..4cba41851 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { @@ -150,7 +153,8 @@ page!(Import { sequence_fps: f64 = 30.0, report_missing_frames: bool = true, /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, + /// checked every two seconds while the window is in front, every ten seconds while it is + /// not, and when it comes back to the front: `off`, /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 609ad5987..4fe47e0ef 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -195,9 +195,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 2eb6fa680..1d60971ac 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -436,7 +458,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index ec5785f7e..857a3e90d 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -5,15 +5,15 @@ //! terminal, so that file is what a bug report can attach. The last few hundred warnings and //! errors are also kept in memory for the System Compatibility Report. //! -//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are -//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target: +//! Levels by default ([`DEFAULT_FILTER`]): `info` for EffectCraft's own crates, `warn` for +//! everything else (wgpu and naga are chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target: //! `effectcraft=debug,wgpu=warn`). //! //! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends //! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches. //! -//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the -//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and +//! Frontends call [`install`] (or [`install_with`] for another default, as the CLI does with `warn`) +//! once at startup and the desktop app [`attach_session_log`] once the settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and //! written first. Writing never panics: a file that can't be created or written leaves standard //! error as the only sink. Never log the control-channel token. @@ -131,11 +131,21 @@ fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: /// Install the logger (once; later calls and other installed loggers are left alone). pub fn install() { + install_with(DEFAULT_FILTER); +} + +/// The directive string: `RUST_LOG` when it is set and nonblank, otherwise `default`. +fn filter_spec<'a>(rust_log: Option<&'a str>, default: &'a str) -> &'a str { + rust_log.filter(|s| !s.trim().is_empty()).unwrap_or(default) +} + +/// [`install`] with `default` as the filter when `RUST_LOG` is unset or empty. +pub fn install_with(default: &str) { static LOGGER: Logger = Logger; if log::set_logger(&LOGGER).is_ok() { let filter = FILTER.get_or_init(|| { - let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); - parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER)) + let spec = std::env::var("RUST_LOG").ok(); + parse_filter(filter_spec(spec.as_deref(), default)) }); log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max)); } @@ -357,6 +367,21 @@ mod tests { assert_eq!(level_for(&f, "naga"), LevelFilter::Warn); } + /// The CLI's default (`warn`): warnings from every crate, no info chatter on stderr, + /// while a nonblank `RUST_LOG` overrides `default`. + #[test] + fn the_cli_filter_shows_only_warnings() { + assert_eq!(filter_spec(None, "warn"), "warn"); + assert_eq!(filter_spec(Some(" "), "warn"), "warn"); + assert_eq!(filter_spec(Some("info"), "warn"), "info"); + let f = parse_filter(filter_spec(None, "warn")); + assert_eq!(level_for(&f, "effectcraft_gpu::context"), LevelFilter::Warn); + assert_eq!(level_for(&f, "effectcraft_engine::render_queue"), LevelFilter::Warn); + assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn); + let over = parse_filter(filter_spec(Some("info"), "warn")); + assert_eq!(level_for(&over, "effectcraft_gpu::context"), LevelFilter::Info); + } + #[test] fn timestamps_are_utc_with_milliseconds() { assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z"); diff --git a/crates/engine/src/system/perf.rs b/crates/engine/src/system/perf.rs index ab1723d9f..929726c95 100644 --- a/crates/engine/src/system/perf.rs +++ b/crates/engine/src/system/perf.rs @@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { *n += 1; layers.push(l); } - comp.layers = layers; + comp.layers = layers.into(); }; let others = spec.comps.saturating_sub(1); let depth = spec.nest_depth.min(others); @@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { n += 1; layers.push(l); } - main.layers = layers; + main.layers = layers.into(); p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); // `Main` must be the first comp `replace_project` opens: give it the smallest id. let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 579d3d30d..26092967f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ mod anim_presets; mod anim_tools; +mod blender; mod camera_track; mod codec_options; mod color_view; @@ -15,8 +16,10 @@ mod frame_export; mod fuzz_open; mod history; mod import_project; +mod instance3d; mod key_metadata_order; mod key_time; +mod keyframe_text; mod keylight; mod limits; mod lottie; @@ -39,6 +42,7 @@ pub(crate) mod roto; mod sequence; mod session; mod settings; +mod shared_layers; mod shortcut_cmds; pub(crate) mod stubs; mod styles; diff --git a/crates/engine/src/tests/anim_presets.rs b/crates/engine/src/tests/anim_presets.rs index b9f529c45..4d8b79e3f 100644 --- a/crates/engine/src/tests/anim_presets.rs +++ b/crates/engine/src/tests/anim_presets.rs @@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() { #[test] fn preset_catalogue_is_unique_and_categorised() { let all = crate::animation_presets(); - let known = ["Transitions", "Backgrounds"]; + let known = ["Transitions", "Backgrounds", "Behaviors"]; let mut ids = std::collections::HashSet::new(); let mut names = std::collections::HashSet::new(); for (id, name, cat) in &all { @@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() { assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes"); } let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count(); - assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20); + assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20); } #[test] @@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() { assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0])); assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2); } + +/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform +/// property, and undo removes it again. +#[test] +fn every_behavior_preset_applies_and_undoes() { + let (mut s, solid) = session(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + let driven = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + ["transform/position", "transform/scale", "transform/rotation", "transform/opacity"] + .iter() + .filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression())) + .copied() + .collect::>() + }; + for p in &list { + let id = p["id"].as_str().unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies"); + assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects"); + let applied = driven(&s); + assert_eq!(applied.len(), 1, "{id}: exactly one property is driven"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(driven(&s).is_empty(), "{id}: undo removes the expression"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(driven(&s), applied, "{id}: redo restores the expression"); + s.execute("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs new file mode 100644 index 000000000..84e939055 --- /dev/null +++ b/crates/engine/src/tests/blender.rs @@ -0,0 +1,261 @@ +//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public +//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture. + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build}; +use effectcraft_time::{FrameRate, Tick}; +use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint}; +use serde_json::{Value, json}; + +use crate::Session; + +fn path(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name).to_string_lossy().into_owned() +} + +fn fixture(ground: bool) -> (Session, LayerId, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + let lid = s + .edit("fixture", None, |p, _| { + p.comp_mut(cid).unwrap().frame_rate = rate; + let item = p.add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { + path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")), + width: 640, + height: 360, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(6.0), + kind: FootageKind::Video, + has_video: true, + ..Default::default() + }), + ); + let comp = p.comp(cid).unwrap().clone(); + let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None); + layer.in_point = rate.tick_of(60); + layer.out_point = rate.tick_of(63); + layer.start_time = rate.tick_of(30); + layer.stretch = 200.0; + let id = layer.id; + p.comp_mut(cid).unwrap().layers.push(layer); + Ok(id) + }) + .unwrap(); + let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap(); + let solve = CameraSolve { + version: 1, + size: [640.0, 360.0], + start: rate.tick_of(30).seconds() / 2.0, + frame_duration: rate.frame_duration().seconds() / 2.0, + shot: ShotType::VariableZoom, + method_used: SolveMethod::Typical, + average_error: 0.12, + frames: (0..3) + .map(|i| SolvedFrame { + rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64], + center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64], + focal: 600.0 + i as f64 * 25.0, + solved: true, + }) + .collect(), + points: vec![ + SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 }, + SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 }, + ], + ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }), + distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }), + }; + // Directly install a deterministic solve; no analysis or decoder is needed by these tests. + s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + s.execute("layer.select", json!({"layers": [lid.0]})).unwrap(); + (s, lid, uid) +} + +fn data(script: &str) -> Value { + let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap(); + let encoded: String = serde_json::from_str(line).unwrap(); + serde_json::from_str(&encoded).unwrap() +} + +#[test] +fn blender_export_bakes_projection_focus_and_retimed_background() { + let (mut s, lid, uid) = fixture(true); + let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap(); + let camera = result["camera"].as_u64().unwrap(); + s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap(); + for (frame, value) in [(60, 800.0), (62, 1000.0)] { + let time = FrameRate::new(30000, 1001).tick_of(frame).seconds(); + s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap(); + } + let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap(); + assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool()); + let output = path("oracle-scene.py"); + let before = s.project.to_json(); + let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap(); + assert_eq!(s.project.to_json(), before, "export must not edit the document"); + assert_eq!(r["frames"], 3); + assert_eq!(r["points"], 2); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert_eq!(d["resolution"], json!([640.0, 360.0])); + assert_eq!(d["fpsNumerator"], 30000); + assert_eq!(d["fpsDenominator"], 1001); + assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101"); + assert_eq!(d["frames"][2]["sourceFrame"], 16); + assert_eq!(d["frames"][0]["focus"], 8.0); + assert_eq!(d["frames"][2]["focus"], 10.0); + assert_eq!(d["frames"][0]["dof"], true); + assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6); + assert_eq!(d["frames"][0]["lens"], 33.75); + assert_eq!(d["frames"][2]["lens"], 36.5625); + assert_eq!(d["ground"], true); + assert_eq!(d["solve"]["distortion"]["k1"], 0.01); + assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane"); + + // Execute the actual exported artifact in a separately installed Blender, when requested. + if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") { + let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py"); + let output = std::process::Command::new(blender) + .args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"]) + .arg(oracle) + .arg("--") + .arg(&output) + .output() + .unwrap(); + assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK")); + } +} + +#[test] +fn blender_export_bakes_reverse_time_remap_and_footage_loops() { + let (mut s, lid, _) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + s.edit("retimed fixture", None, |p, _| { + let layer = p.comp(cid).unwrap().layer(lid).unwrap(); + let item = layer.source.item().unwrap(); + let keys = [(60, 8.0), (61, 6.0), (62, -1.0)] + .map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time))); + let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0)); + remap.keys = keys.to_vec(); + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into()); + if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind { + f.loop_count = 2; + } + Ok(()) + }) + .unwrap(); + let output = path("remap.py"); + s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap(); + let d = data(&std::fs::read_to_string(output).unwrap()); + let indices: Vec = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect(); + assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does"); +} + +#[test] +fn blender_export_defaults_and_script_escaping() { + let (mut s, lid, _) = fixture(false); + let output = path("escaped.py"); + let r = s + .execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1})) + .unwrap(); + assert_eq!(r["points"], 0); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert!(d["name"].as_str().unwrap().contains("'\\ — câmera")); + assert_eq!(d["frames"][0]["lens"], 22.5); + assert_eq!(d["frames"][0]["dof"], false); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered"))); + match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() { + Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"), + Err(e) => panic!("Python AST oracle failed: {e}"), + } +} + +#[test] +fn blender_export_rejects_invalid_arguments_and_write_failures() { + let (mut s, lid, _) = fixture(false); + for params in [ + json!({"sensorWidth": 0}), + json!({"sensorWidth": "wide"}), + json!({"unitScale": -1}), + json!({"startFrame": -1}), + json!({"startFrame": 1.5}), + json!({"startFrame": 1048574}), + json!({"includePoints": "yes"}), + json!({"bogus": true}), + json!({"path": path("bad.blend")}), + ] { + let mut p = json!({"layer": lid.0, "path": path("invalid.py")}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}"); + } + let blocker = path("blocker"); + std::fs::write(&blocker, b"not a directory").unwrap(); + let p = format!("{blocker}/scene.py"); + assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write")); +} + +#[test] +fn blender_export_rejects_malformed_solves_without_panicking() { + for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] { + let (mut s, lid, uid) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())) + .unwrap() + .as_ref() + .clone(); + match variant { + "empty" => solve.frames.clear(), + "unsolved" => solve.frames[0].solved = false, + "zero-focal" => solve.frames[0].focal = 0.0, + "bad-ground" => solve.ground = Some(Ground::default()), + "huge-resolution" => solve.size[0] = 1e10, + _ => { + solve.frames.pop(); + } + } + s.edit("corrupt solve", None, |p, _| { + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json()); + Ok(()) + }) + .unwrap(); + s.camera_pending.clear(); + let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")})); + assert!(result.is_err(), "must reject {variant}"); + } +} + +#[test] +fn blender_export_respects_script_write_permission() { + let (mut s, lid, _) = fixture(false); + let output = path("script-permissions.py"); + let params = json!({"layer": lid.0, "path": output}); + for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] { + std::fs::write(&output, b"existing file").unwrap(); + s.prefs.scripting.allow_scripts_write_files = allowed; + let result = + if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) }; + assert!(!s.running_for_script(), "script origin must be restored after success or error"); + let contents = std::fs::read_to_string(&output).unwrap(); + if from_script && !allowed { + assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files")); + assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file"); + } else { + assert_eq!(result.unwrap()["frames"], 3); + assert!(contents.contains("def import_scene():")); + } + } +} diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index eacae55af..2e61f57a1 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); let layer = &comp.layers[0]; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; diff --git a/crates/engine/src/tests/disk_cache.rs b/crates/engine/src/tests/disk_cache.rs index 119699aa8..41497dd4b 100644 --- a/crates/engine/src/tests/disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() { assert_ne!(k3, k2); assert_ne!(set_proxy(&mut s, false), k3); } + +/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to +/// render again, #1083); an expression that reads markers makes them count. +#[test] +fn markers_keep_the_content_key_unless_an_expression_reads_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap(); + assert_eq!(key(&s), k0, "markers"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0); + s.execute("comp.addMarker", json!({"time": 1.0})).unwrap(); + assert_ne!(key(&s), k1, "a marker read by an expression"); +} + +/// A comp marker alone (no layer marker beside it) keeps the key too. +#[test] +fn a_lone_comp_marker_keeps_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0); +} + +/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so +/// changing it changes the key of the comp that nests it; a plain marker beside it does not. +#[test] +fn protected_markers_with_a_duration_change_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("layer.timeStretch", json!({"percent": 200})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0, "a plain marker"); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap(); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0, "a protected marker with a duration"); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap(); + assert_ne!(key(&s), k1, "its duration"); +} diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs index 1ea57f4cf..4b3b0188e 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() { effectcraft_project::ItemKind::Comp(c) => c, _ => panic!("not a composition"), }); - let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap(); + let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap(); for n in &mut l.props.sub_mut("effects").unwrap().children { let Node::Group(g) = n else { continue }; if g.match_id == "ec.blur.gaussian" { @@ -772,6 +772,132 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a +/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value +/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the +/// old id applies to the new one. +#[test] +fn applying_an_old_preset_upgrades_its_effects_and_property_paths() { + let (mut s, a, b) = comp_with_two_solids(); + apply(&mut s, a, "Change to Color"); + let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone(); + old.children.retain(|c| c.match_id() != "toleranceGroup"); + old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into()); + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + let l = layer(&s, b); + let g = l.effects().unwrap().groups().next().unwrap(); + assert!(g.get("tolerance").is_none(), "the old Tolerance is still there"); + let tol = g.sub("toleranceGroup").expect("no Tolerance group"); + assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0)); + assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0)); + assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0)); + let path = "effects/ec.color.changetocolor/tolerance"; + let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []}); + let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap(); + assert_eq!(r["applied"], 1, "{r}"); + assert_eq!(r["skipped"], json!([]), "{r}"); + let l = layer(&s, a); + let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone(); + assert_eq!(hue, KV::Scalar(30.0)); +} + +/// Save the selected effects and properties as a preset, then apply it to `target` at `at`. +fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value { + let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string(); + s.state.selected_props = selected; + s.execute("anim.savePreset", json!({"path": path})).unwrap(); + s.execute("time.set", json!({"time": at})).unwrap(); + let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]})); + let _ = std::fs::remove_file(&path); + r.unwrap() +} + +/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when +/// `n` is `None`. +fn key_times(s: &Session, lid: u64, n: Option, path: &str) -> Vec { + let l = layer(s, lid); + let group = match n { + Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(), + None => l.props.clone(), + }; + group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect() +} + +/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved +/// property's do, nested controls included, an effect without keys still applies, and the whole +/// apply is one undo step. +#[test] +fn preset_effect_keys_start_at_the_playhead() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + let fractal = apply(&mut s, a, "Fractal"); + let slider = apply(&mut s, a, "Slider Control"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]); + animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]); + let before = layer(&s, b); + let undo = s.history.undo.len(); + let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5); + assert_eq!(r["applied"], 3, "{r}"); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]); + assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]); + assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, b), before); +} + +/// #1971: effects and properties saved together keep their relative timing: the earliest key of +/// either starts at the playhead. +#[test] +fn preset_effects_and_properties_share_one_anchor() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + for (time, value) in [(4.0, -12.0), (7.0, 12.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap(); + } + let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid; + save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0); + assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]); +} + +/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do. +#[test] +fn preset_effect_keys_follow_a_stretched_layer_like_properties() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + for (time, value) in [(5.0, -12.0), (6.0, 12.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap(); + } + let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid; + set_layer_clock(&mut s, b, 1.0, 200.0); + save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0); + let property = key_times(&s, b, None, "transform/rotation"); + assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead"); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them"); +} + +/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1) +/// has its effects' keys at the source layer's times; they also start at the playhead. +#[test] +fn old_preset_effect_keys_start_at_the_playhead() { + let (mut s, a, b) = comp_with_two_solids(); + let blur = apply(&mut s, a, "Gaussian Blur"); + animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]); + let old = json!({ + "effectcraftAnimationPreset": 1, + "name": "shake", + "properties": [], + "effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()], + }); + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap(); + assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]); +} + /// #2064: `prop.addKey` accepts the `effects/[#n]/` path that /// `essential.list` reports. #[test] diff --git a/crates/engine/src/tests/essential.rs b/crates/engine/src/tests/essential.rs index 191416b9d..db5bf0751 100644 --- a/crates/engine/src/tests/essential.rs +++ b/crates/engine/src/tests/essential.rs @@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() { assert!(center(&s, main)[0] > 0.99); } +/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested +/// layers past the precomp's frame instead of falling back to a flattened, clipped frame. +#[test] +fn collapsed_precomp_applies_property_overrides_without_clipping() { + struct AddY; + impl effectcraft_render::ExprHost for AddY { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + value: &effectcraft_keyframe::Value, + ) -> Result { + Ok(match value { + effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]), + _ => value.clone(), + }) + } + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + // The source property's expression still runs after the instance override. + s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap(); + s.expr = Some(std::sync::Arc::new(AddY)); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [other]})).unwrap(); + s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + // The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to + // outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above + // the instance's frame (150..449), where a flattened precomp would stop. The second maps to + // 470 and spans 420..519, past the bottom. + assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100)); + assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds"); + assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500)); + assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances"); +} + +/// #1747: two collapsed instances of the same comp keep their own source-affecting property +/// overrides in the layer cache (the nested layers' ids and static values are identical). +#[test] +fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let card = s.active_comp_id().unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap(); + let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let img = s.render(main, Tick::ZERO, RenderOpts::default()); + let left = img.get(60, 50); + let right = img.get(140, 50); + assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}"); + assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}"); +} + +/// #1747 with Media Replacement: the replacement footage and the position override both apply +/// while the precomp stays collapsed, so the box is blue and still draws above the frame. +#[test] +fn collapsed_precomp_applies_media_replacement_without_clipping() { + struct Colors; + impl crate::render::FootageSource for Colors { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c))) + } + } + let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() }; + let footage = |path: &str| effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 100, + height: 100, + has_video: true, + ..Default::default() + }; + let (red, blue) = s + .edit("test", None, |proj, _| { + let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png"))); + let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png"))); + Ok((red, blue)) + }) + .unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + let above = img.get(200, 100); + assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}"); + assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame"); +} + /// Responsive Design — Time: stretching a precomp keeps its protected regions at their /// original speed; the unprotected parts absorb the stretch. #[test] diff --git a/crates/engine/src/tests/essential_more.rs b/crates/engine/src/tests/essential_more.rs index a8d8dc808..ea4aa42e5 100644 --- a/crates/engine/src/tests/essential_more.rs +++ b/crates/engine/src/tests/essential_more.rs @@ -2,6 +2,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType}; +use effectcraft_time::Tick; use serde_json::json; use crate::{Event, Session}; @@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() { ); assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); } + +/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source +/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides` +/// does on the flattened path, instead of one entry replacing the other. +#[test] +fn collapsed_instance_merges_text_and_font_overrides() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let main = s.active_comp_id().unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + // Evaluate the nested text in the collapsed instance's context, as the renderer does. + let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default()); + let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO); + let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap(); + let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses"); + let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context"); + let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap(); + match nctx.value(src, src.props.prop("text/sourceText").unwrap()) { + KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } +} diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index a45c5d444..a8dc938fb 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() { assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); } + +#[test] +fn save_frame_as_png_preserves_alpha_channel() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap(); + let dir = out_dir("frame-png-alpha"); + let path = dir.join("alpha.png").to_string_lossy().to_string(); + + s.execute("comp.saveFrameAs", json!({"path": path})).unwrap(); + let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8(); + assert_eq!((img.width(), img.height()), (32, 32)); + let solid_px = img.get_pixel(8, 8); + assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}"); + assert_eq!(solid_px[0], 255); + let empty_px = img.get_pixel(24, 24); + assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}"); + + let flat_path = dir.join("flat.png").to_string_lossy().to_string(); + s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap(); + let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8(); + let flat_empty_px = flat_img.get_pixel(24, 24); + assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}"); +} + +#[test] +fn save_frame_as_queued_respects_frame_default_template() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + + // Default Still template is "PNG Sequence" (Channels::Rgb) + let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // Test undo and redo of queuing a frame + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + + // Explicit transparent: false forces Channels::Rgb + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // When Frame Default is set to an RGBA template, it is respected + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap(); + s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); + + // Passing transparent: false forces Channels::Rgb even with an RGBA template + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap(); + assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok()); +} diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs new file mode 100644 index 000000000..48f4b7730 --- /dev/null +++ b/crates/engine/src/tests/instance3d.rs @@ -0,0 +1,257 @@ +//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack +//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its +//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde). + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project}; +use effectcraft_render::{FootageSource, RenderOpts}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::{Importer, Session}; + +fn session() -> Session { + let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +fn cid(s: &Session) -> ItemId { + s.active_comp_id().unwrap() +} + +fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer { + s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] { + img.data[(y * img.width + x) as usize] +} + +fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) +} + +struct DiskModels; + +impl FootageSource for DiskModels { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + let bytes = std::fs::read(&f.path).ok()?; + effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new) + } +} + +struct ModelImporter; + +impl Importer for ModelImporter { + fn probe(&self, path: &str) -> Result { + effectcraft_model::format_of(path).ok_or("not a model")?; + Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() }) + } +} + +/// A red triangle in the Z = 0 plane (buffer embedded as a data URI). +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +fn triangle_model(s: &mut Session) -> u64 { + let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Triangle.gltf"); + std::fs::write(&path, TRIANGLE).unwrap(); + s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn instance3d_creates_a_2d_copy_of_the_model() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model renders through the comp's camera. + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180)); + assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4)); + // Create 3D Layer Instance. + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let comp = cid(&s); + let l = layer_in(&s, comp, solid); + assert_eq!(l.name, "Triangle Instance"); + assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack"); + // A 3D Layer Instance effect referencing the 3D layer. + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); + // The model is switched off, the copy selected. + assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does"); + assert_eq!(s.state.selected_layers, vec![LayerId(solid)]); + // The comp renders the same through the copy: a 2D copy of the 3D layer, transparent + // around it. + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); + assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4)); + // Undo removes the copy and switches the model back on; redo returns it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none()); + assert!(layer_in(&s, comp, model).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some()); + assert!(!layer_in(&s, comp, model).switches.video); + // Serde round-trips the layer parameter. + let j = serde_json::to_string(&*s.project).unwrap(); + let back: Project = serde_json::from_str(&j).unwrap(); + assert_eq!(back, *s.project); + // 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue + // recolours the triangle. + s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default()); + let p = px(&with_fx, 320, 180); + assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}"); +} + +#[test] +fn instance3d_also_takes_3d_primitives() { + let mut s = session(); + let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap(); + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let l = layer_in(&s, cid(&s), solid); + assert_eq!(l.name, "Cube Instance"); + assert!(!layer_in(&s, cid(&s), prim).switches.video); + let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the cube"); +} + +#[test] +fn instance3d_needs_a_3d_layer() { + let mut s = session(); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]})); + assert!(err.is_err()); + let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); + // A 3D-switched text layer is not a model layer either (it has 2D pixels of its own). + s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); +} + +#[test] +fn instance3d_creates_every_selected_copy_in_one_undo() { + let mut s = session(); + let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 2); + let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap()); + let comp = cid(&s); + assert_ne!(ia, ib); + assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own"); + // Both sources are off, both copies selected, above their sources in the stack. + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); + let mut sel = s.state.selected_layers.clone(); + let mut want = vec![LayerId(ia), LayerId(ib)]; + sel.sort_by_key(|l| l.0); + want.sort_by_key(|l| l.0); + assert_eq!(sel, want); + let c = s.active_comp().unwrap(); + let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap(); + assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources"); + // One undo removes both copies and switches both models back on; one redo returns them. + s.execute("edit.undo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance"); + assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance"); + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); +} + +#[test] +fn instance3d_in_a_mixed_selection_only_takes_the_models() { + let mut s = session(); + let model = triangle_model(&mut s); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 1, "only the model gets a copy: {r}"); + let copy = copies[0].as_u64().unwrap(); + let comp = cid(&s); + assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off"); + assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched"); + let skipped = r["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 1); + assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}"); + // The copy references the model, not the solid. + let l = layer_in(&s, comp, copy); + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); +} + +#[test] +fn instance3d_shows_nothing_outside_the_source_layer_range() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model's range ends at 1 s; the comp runs to 2 s. + let comp = cid(&s); + s.edit("trim", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let _copy = r["layers"][0].as_u64().unwrap(); + // Inside the model's range the copy shows it, transparent around it. + let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default()); + assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180)); + // After its out point the source has nothing to read: the copy is transparent. + let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default()); + assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180)); +} + +#[test] +fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() { + let mut s = session(); + let model = triangle_model(&mut s); + let comp = cid(&s); + // Solo the model, then create the instance: any_solo hides non-soloed layers and the + // source's video goes off, so the copy has to take the Solo over. + s.edit("solo", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true; + Ok(()) + }) + .unwrap(); + let before = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180)); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch"); + assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off"); + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180)); + assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); +} diff --git a/crates/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs new file mode 100644 index 000000000..30d3b436e --- /dev/null +++ b/crates/engine/src/tests/keyframe_text.rs @@ -0,0 +1,482 @@ +//! Copied keyframes as After Effects keyframe text for other applications (#1908). + +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::{KeyRef, Session}; + +/// A 1920×1080 23.976 fps comp with a 200×100 solid. +fn session() -> (Session, LayerId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + (s, LayerId(id)) +} + +fn frame(s: &Session, n: i64) -> Tick { + Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n) +} + +fn uid(s: &Session, lid: LayerId, path: &str) -> Uid { + s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid +} + +fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) { + let cid = s.active_comp_id().unwrap(); + f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap()); +} + +fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) { + s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap(); +} + +#[test] +fn copied_keys_are_after_effects_text_in_comp_frames() { + let (mut s, l) = session(); + // The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19. + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] { + let t = frame(&s, n); + add_key(&mut s, l, "transform/position", t, json!(v)); + } + let t = frame(&s, 5); + add_key(&mut s, l, "transform/rotation", t, json!(-45)); + let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation")); + // A subset: two of the three Position keys and the Rotation key. + s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!( + s.state.keyframe_text.as_deref(), + Some( + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Layer\n\ + Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\ + Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\ + \n\ + End of Keyframe Data\n" + ) + ); + // The internal clipboard still has the keys themselves, relative to the earliest. + let times: Vec> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect(); + assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]); +} + +#[test] +fn a_stretched_layer_writes_the_frames_its_keys_show_at() { + let (mut s, l) = session(); + edit_layer(&mut s, l, |l| l.stretch = 200.0); + let t = frame(&s, 6); + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0)); + add_key(&mut s, l, "transform/opacity", t, json!(100)); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}"); +} + +#[test] +fn copied_static_values_have_no_frame() { + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}"); + assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value"); +} + +#[test] +fn a_copied_transform_group_lists_what_the_layer_shows() { + let (mut s, l) = session(); + let t = frame(&s, 24); + add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100])); + add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100])); + let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); + assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}"); + // 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three + // values sit under one label, as After Effects writes them. + edit_layer(&mut s, l, |l| l.switches.three_d = true); + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}"); +} + +fn copy_transform(s: &mut Session, lid: LayerId) -> String { + let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +fn names(text: &str) -> Vec<&str> { + text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect() +} + +#[test] +fn properties_the_layer_does_not_use_are_left_out() { + // Separate Dimensions: the old Position no longer moves the layer. + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap(); + let text = copy_transform(&mut s, l); + assert!(!names(&text).contains(&"Position"), "{text}"); + // A one-node camera has no Point of Interest; a two-node one does. + let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, cam); + assert!(!names(&text).contains(&"Point of Interest"), "{text}"); + s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap(); + let text = copy_transform(&mut s, cam); + assert!(names(&text).contains(&"Point of Interest"), "{text}"); +} + +#[test] +fn separated_position_writes_x_y_and_z_position() { + let (mut s, l) = session(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100)); + add_key(&mut s, l, "transform/positionX", t, json!(321)); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap(); + let text = copy_transform(&mut s, l); + // After Effects labels their single column with no unit. + assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}"); + assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_transform(&mut s, l); + assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}"); +} + +#[test] +fn time_remap_writes_source_seconds_at_comp_frames() { + let (mut s, l) = session(); + let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap(); + let l = LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layers": [l.0]})).unwrap(); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + let t = frame(&s, 6); + add_key(&mut s, l, "timeRemap", t, json!(2.5)); + s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + // As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16. + assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}"); +} + +/// Selects the layer's properties at `paths` and copies them. +fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { + for (i, path) in paths.iter().enumerate() { + s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap(); + } + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +#[test] +fn effect_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown + // Menu Control: that block follows its other popups, written 1-based with no unit). + let (mut s, l) = session(); + for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let t = frame(&s, 12); + add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1])); + add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1])); + add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true)); + add_key(&mut s, l, "effects/#2/checkbox", t, json!(false)); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap(); + // After Effects writes a renamed effect under the effect's own name. + let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]); + assert!( + text.contains( + "Layer\n\ + Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\ + Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\ + Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\ + Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\ + Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n" + ), + "{text}" + ); +} + +#[test] +fn effect_properties_have_after_effects_indices() { + // After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9, + // Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a + // group takes an index where it opens and closes, and its properties are written without + // it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for + // its source choice. + let (mut s, l) = session(); + for fx in ["Fractal Noise", "Set Matte"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &[ + "effects/#1/transform/rotation", + "effects/#1/complexity", + "effects/#1/evolution", + "effects/#1/opacity", + "effects/#1/blendingMode", + "effects/#2/takeMatteFrom", + ], + ); + for header in [ + "Effects\tFractal Noise #1\tRotation #9\n", + "Effects\tFractal Noise #1\tComplexity #17\n", + "Effects\tFractal Noise #1\tEvolution #25\n", + "Effects\tFractal Noise #1\tOpacity #31\n", + "Effects\tFractal Noise #1\tBlending Mode #32\n", + "Effects\tSet Matte #2\tUse For Matte #3\n", + ] { + assert!(text.contains(header), "{header}in\n{text}"); + } +} + +#[test] +fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() { + // After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no + // unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color + // sliders start at #10 and are percent. + let (mut s, l) = session(); + for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"], + ); + for block in [ + "Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n", + "Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n", + "Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n", + "Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n", + "Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + +#[test] +fn mask_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not + // name, and Mask Path is left out. + let (mut s, l) = session(); + for _ in 0..2 { + s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + } + let t = frame(&s, 12); + for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] { + add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a); + add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b); + } + s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap(); + let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]); + assert!( + text.contains( + "Layer\n\ + Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\ + Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\ + Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\ + Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\ + \nEnd of Keyframe Data\n" + ), + "{text}" + ); +} + +#[test] +fn text_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same text properties and values. + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap(); + let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap(); + s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello")); + add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100)); + add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0])); + add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0])); + for (path, v) in [ + ("text/animators/#1/selectors/#1/end", json!(80)), + ("text/animators/#1/selectors/#1/offset", json!(-10)), + ("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])), + ("text/moreOptions/groupingAlignment", json!([10, -20])), + ] { + s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &[ + "text/sourceText", + "text/pathOptions/firstMargin", + "text/animators/#1/selectors/#1/start", + "text/animators/#1/selectors/#1/end", + "text/animators/#1/selectors/#1/offset", + "text/animators/#1/selectors/#1/advanced/amount", + "text/animators/#1/properties/position", + "text/animators/#1/properties/fillColor", + "text/animators/#2/properties/opacity", + "text/moreOptions/groupingAlignment", + ], + ); + for block in [ + "Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n", + "Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n", + ] { + assert!(text.contains(block), "{block}\nin\n{text}"); + } + assert!(!text.contains("Source Text"), "{text}"); +} + +#[test] +fn a_2d_layer_writes_the_z_it_shows() { + // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. + let (mut s, l) = session(); + let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + edit_layer(&mut s, src, |l| l.switches.three_d = true); + add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300])); + add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70])); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap(); + let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components(); + assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z"); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}"); + assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}"); +} + +#[test] +fn the_header_has_the_source_pixel_aspect() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap(); + let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, solid); + assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect. + let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, t); + assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A shape layer has no source of its own: the comp's size. + let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, shape); + assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}"); +} + +#[test] +fn keys_from_two_layers_have_no_text() { + let (mut s, a) = session(); + let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + for l in [a, b] { + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50)); + } + s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text"); + assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both"); + // A copied layer leaves no text behind. + s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }]; + s.execute("keys.copy", json!({})).unwrap(); + assert!(s.state.keyframe_text.is_some()); + s.state.selected_keys.clear(); + s.execute("edit.copy", json!({"layers": [a.0]})).unwrap(); + assert_eq!(s.state.keyframe_text, None); +} + +#[test] +fn shape_contents_are_written_as_after_effects_does() { + // Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and + // place in their group, and a Size has no unit (`X `). + let (mut s, _) = session(); + let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap()); + for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] { + let mut p = json!({"layer": sh.0, "kind": kind}); + if let Some(g) = group { + p["group"] = json!(g); + } + s.execute("layer.addShapeItem", p).unwrap(); + } + let text = copy_props( + &mut s, + sh, + &[ + "contents/#1/contents/#1/size", + "contents/#1/contents/#2/color", + "contents/#1/transform/position", + "contents/#2/points", + "contents/#3/transform/startOpacity", + ], + ); + for block in [ + "Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n", + "Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n", + "Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n", + "Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n", + "Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + +#[test] +fn camera_light_and_material_options_are_written_as_after_effects_does() { + // Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow + // Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0. + let (mut s, l) = session(); + let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]); + assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}"); + assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}"); + let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]); + assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}"); + assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]); + for block in [ + "Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n", + "Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n", + "Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index eb99d809f..cfbbb4966 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() { assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } +/// The layer's painted bounds mapped to comp space — what the viewer shows. +fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap(); + let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time()); + let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds"); + let m = ectx.layer_to_comp(layer).0; + let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + let pts: Vec<[f64; 2]> = corners + .iter() + .map(|p| { + let q = m.apply(effectcraft_geom::vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in &pts { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + [x0, y0, x1, y1] +} + +#[test] +fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { + let mut s = comp(); + let close = |x: f64, y: f64| (x - y).abs() < 1e-6; + // A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at + // the bounds' centre, so what the viewer shows fills the frame (#2198). + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, a); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}"); + + let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, t); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}"); + + // fitWidth spans the comp's width and centres vertically. + for lid in [a, t] { + s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap(); + let seen = seen_bounds(&s, lid); + assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}"); + assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}"); + } +} + +#[test] +fn fit_to_comp_refuses_a_scale_that_overflows() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap(); + let before = s.project.clone(); + assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused"); + assert_eq!(s.project, before, "nothing was written"); +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); @@ -1423,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { assert!(s.state.selected_keys.is_empty()); } +#[test] +fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64(); + let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + select(&mut s); + let before = value_at(&s, 0.5); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + // The muted key no longer bends the 0 → 300 segment. + assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6); + assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6); + s.undo(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + assert!((value_at(&s, 0.5) - before).abs() < 1e-6); + // The same command unmutes. + select(&mut s); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + s.execute("keys.setMuted", json!({"muted": false})).unwrap(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); +} + +/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work. +#[test] +fn mute_and_unmute_keyframes_menu_rows_work_and_undo() { + fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) { + for n in nodes { + match n { + crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())), + crate::menus::MenuNode::Submenu { children, .. } => rows(children, out), + _ => {} + } + } + } + let mut menu = vec![]; + rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu); + assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}"); + + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted; + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + let run = |s: &mut Session, params: &Value| { + s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap(); + }; + run(&mut s, &menu[0].1); + assert!(muted(&s)); + s.undo(); + assert!(!muted(&s)); + s.redo(); + assert!(muted(&s)); + run(&mut s, &menu[1].1); + assert!(!muted(&s)); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/engine/src/tests/panels.rs b/crates/engine/src/tests/panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests/panels.rs +++ b/crates/engine/src/tests/panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 16e0cb00c..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -14,7 +14,8 @@ use crate::Session; use crate::render::{FootageSource, RenderOpts}; /// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced: -/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a +/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from +/// the second frame), `matte_black.png` / `matte_white.png` (a /// semi-transparent green premultiplied against black / white, read as straight), `half.png` /// (white at 25 % alpha), `wide.png` (10×10 white). struct Synthetic; @@ -35,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -46,19 +53,29 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } impl FootageSource for Synthetic { - fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option> { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h); + // `flash.mov` is red for its first frame and blue from its second (0.1 s) on. + if f.path.ends_with("flash.mov") { + let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + return Some(Arc::new(Image::filled(w, h, c))); + } for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -70,7 +87,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -171,6 +188,118 @@ fn comp_proxy_and_create_proxy() { assert!(blue(px(&s, outer, 0.0, o, 8, 8))); } +/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer). +fn nested(s: &mut Session) -> (ItemId, ItemId) { + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + (inner, outer) +} + +/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a +/// composition proxy draws, as they do for the same file used as footage. +#[test] +fn comp_proxy_draws_with_its_interpretation() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + // Invert Alpha: the 25 % proxy covers 75 %. + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((p[3] - 0.75).abs() < 0.01, "{p:?}"); + // Interpret As Linear Light: the proxy draws as the file does as footage with that setting. + let f = import(&mut s, "/m/matte_black.png"); + s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap(); + s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap(); + let r = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": f.0})).unwrap(); + let want = px(&s, r, 0.0, o, 1, 1); + assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}"); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap(); + let p = px(&s, outer, 0.0, o, 1, 1); + assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}"); +} + +/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame +/// at twice the rate, as footage does. +#[test] +fn comp_proxy_separates_fields() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive"); + s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap(); + // At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated. + for y in 0..3 { + assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}"); + } +} + +/// Frame Blending on a precomp layer blends the frames of the composition's proxy. +#[test] +fn comp_proxy_frames_blend() { + let mut s = session(); + let (inner, outer) = nested(&mut s); + let o = RenderOpts::default(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap(); + assert!(red(px(&s, outer, 0.05, o, 1, 1))); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + // Halfway between the red first frame and the blue second one. + let p = px(&s, outer, 0.05, o, 1, 1); + assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}"); + assert!(blue(px(&s, outer, 0.1, o, 1, 1))); +} + +#[test] +fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() { + struct Interpreted(std::sync::atomic::AtomicUsize); + impl FootageSource for Interpreted { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let pixel = match f.alpha { + AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5], + AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5], + AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0], + }; + Some(Arc::new(Image::filled(4, 4, pixel))) + } + } + for mode in ["frameMix", "pixelMotion"] { + let mut s = session(); + let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0))); + s.footage = footage.clone(); + let (inner, outer) = nested(&mut s); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap(); + s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": mode})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap(); + let opts = RenderOpts::default(); + let first = px(&s, outer, 0.05, opts, 1, 1); + let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first); + assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls); + for params in [ + json!({"item": inner.0, "alpha": "ignore"}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}), + json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}), + ] { + s.execute("file.interpretProxy", params.clone()).unwrap(); + let mut fresh = s.renderer(opts); + fresh.cache = None; + let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1); + let actual = px(&s, outer, 0.05, opts, 1, 1); + assert_eq!(actual, want, "{mode} after {params}"); + } + } +} + #[test] fn interpret_proxy_settings_apply_to_the_proxy() { let mut s = session(); @@ -187,6 +316,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() { assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation"); } +/// Agents and the control channel run commands through `execute_checked`, which refuses keys a +/// params doc does not list. The Interpret Footage commands list every key they read. +#[test] +fn interpret_commands_accept_their_keys_when_checked() { + let mut s = session(); + let mov = import(&mut s, "/m/lines.mov"); + let b = import(&mut s, "/m/red16.png"); + s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap(); + s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap(); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap(); + let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage; + assert!(px.preserve_rgb); + assert_eq!(px.start_timecode, Some(25)); + s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap(); + assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied); + s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap(); + assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]); + // `item` names one footage item, as `items` names several. + s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap(); + s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap(); + s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap(); + let f = match &s.project.item(mov).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + assert!(f.invert_alpha); + assert_eq!(f.premul_color, [0.0, 1.0, 0.0]); + assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1); +} + /// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines /// interpolated; the dominant field comes first. #[test] @@ -281,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d801a743a..cf2518447 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. +#[test] +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. +#[test] +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); + } +} + +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); + // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + let orientation = layer_in(&s, cid(&s), light); + let keys = &orientation.props.prop("transform/orientation").unwrap().keys; + let last = keys.iter().rev().nth(1).expect("the penultimate key"); + assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing"); +} + // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 423af1d2f..64ee30d5e 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec { img.data.iter().map(|p| p[3]).collect() } +/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins, +/// in the render and through Undo / Redo (#1646). +#[test] +fn roto_brush_strokes_correct_earlier_ones() { + let (mut s, clip, _) = painted(center, "disk-1646.mov"); + let c = center(0); + let spot = [c[0], c[1] + 25.0]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let paint = |s: &mut Session, kind: &str| { + s.execute_checked( + "roto.stroke", + json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}), + ) + .unwrap(); + }; + assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte"); + paint(&mut s, "bg"); + assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it"); + paint(&mut s, "fg"); + assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it"); + assert!(iou_at(&mut s, clip, 0, c) > 0.95); + assert!(s.undo()); + assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back"); + assert!(s.redo()); + assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again"); +} + #[test] fn roto_brush_segments_and_propagates() { let (mut s, clip, uid) = painted(center, "disk-a.mov"); diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index 3e81961ce..cf6968e3f 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() { let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); - // Scripts and agents naming one frame without `sequence` still get its run. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); - let (_, f) = footage(&s, &r["items"][0]); - assert_eq!(f.kind, FootageKind::Sequence); + // #2018: scripts and agents naming one frame without `sequence` get that still, as + // `ImportOptions.sequence` defaults to false in After Effects. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still)); } #[test] @@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() { } #[test] -fn replace_footage_and_reload_keep_sequences() { +fn replace_footage_keeps_a_still_a_still() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // Replacing with a numbered still does not silently turn the item into a sequence (#2073). s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); let (name, f) = footage(&s, &item); - assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // JSON null is unspecified, not "on": the still stays a still. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Alphabetical is a sequence modifier, not a request to make one. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Asking for the run still brings it. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); +} + +#[test] +fn replace_footage_keeps_a_one_frame_run_a_sequence() { + let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // A numbered file with no other frame in its run is still the replacement for a sequence. + s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1)); + // A single-file source keeps the sequence kind too. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1)); + // An explicit `sequence: false` still asks for one frame. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap(); + let (_, f) = footage(&s, &item); + assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0)); +} + +#[test] +fn replace_footage_and_reload_keep_sequences() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); // Reload finds a new frame and keeps the interpretation. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap(); std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b5d1cbb5f..a29de9f1c 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { s.execute("file.import", params).unwrap(); let comp = s.active_comp().unwrap(); assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["a.png", "b.png", "Bg"]); - for l in &comp.layers[..2] { + for l in comp.layers.iter().take(2) { assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0]))); assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0)); } @@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() { assert_eq!(r["items"].as_array().map(Vec::len), Some(1)); } +/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what +/// the import made, first file on top, at once or when the background job lands; an import that +/// made nothing makes no comp. +#[test] +fn new_comp_from_footage_makes_a_comp_of_what_was_imported() { + use effectcraft_project::{Footage, FootageKind, ItemKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + if path.ends_with(".xyz") { + return Err("unsupported file".into()); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::>(); + // At once. + s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 1); + assert_eq!(layers(&s), ["a.png"]); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32)); + // In the background: the job's own items, whatever is selected or active meanwhile; it takes + // precedence over `addToComp`. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap(); + s.state.project_selection.clear(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(layers(&s), ["c.png", "d.png"]); + // Nothing imported: no comp, and the failure is reported. + s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert!(s.job_log.last().unwrap().message.contains("unsupported file")); + let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1)); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { @@ -476,6 +516,9 @@ fn set_text_paragraph_fill_and_leading() { s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap(); let d = doc(&s); assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true)); + s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap(); + assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel); + assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err()); } #[test] diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..f2fd5a3b7 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] @@ -1072,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Polish, likewise. + assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl")); + let mut pl = Session { config: Some(store.clone()), ..Default::default() }; + pl.load_settings(); + assert_eq!(pl.prefs.general.language, "pl"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl"); // Georgian too. assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); diff --git a/crates/engine/src/tests/shared_layers.rs b/crates/engine/src/tests/shared_layers.rs new file mode 100644 index 000000000..275fdd624 --- /dev/null +++ b/crates/engine/src/tests/shared_layers.rs @@ -0,0 +1,42 @@ +//! An edit copies only the layer it changes: the others stay shared with the undo state, so an +//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic). + +use std::sync::Arc; + +use serde_json::json; + +use crate::Session; + +#[test] +fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap(); + let ids: Vec = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect(); + let cid = s.active_comp_id().unwrap(); + let before = s.project.clone(); + s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap(); + let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap()); + assert_eq!(old.layers.len(), new.layers.len()); + let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap(); + for i in 0..new.layers.len() { + assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i)); + } + // Undo brings back the very same state, and the edited layer is the only one that differed. + s.execute("edit.undo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.project, &before)); +} + +#[test] +fn layers_save_and_load_as_a_plain_list() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap(); + for i in 0..3 { + s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let text = serde_json::to_value(&comp.layers).unwrap(); + assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before"); + let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap(); + assert_eq!(&back, &comp.layers); +} diff --git a/crates/engine/src/tests/shortcut_cmds.rs b/crates/engine/src/tests/shortcut_cmds.rs index dd306ef93..c11885db1 100644 --- a/crates/engine/src/tests/shortcut_cmds.rs +++ b/crates/engine/src/tests/shortcut_cmds.rs @@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() { assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err()); } +#[test] +fn trimming_out_to_the_playhead_keeps_its_frame_visible() { + let (mut s, ids) = session([(1.0, 9.0); 3]); + let frame = 1.0 / 30.0; + // Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last + // visible frame — it must not be cut away at the playhead (as in After Effects). + s.execute("time.set", json!({"time": 4.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0])); + // Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap(); + assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1])); + // Trimmed before the In point, the Out point keeps one frame of layer. + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2])); + // One undo step back to the original span. + assert!(s.undo()); + assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9); +} + #[test] fn masks_are_stepped_through_and_wrap() { let (mut s, ids) = session([(0.0, 10.0); 3]); diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 84321ee7e..087a4ae1a 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() { }; let radius = |v: [f64; 2]| v[0].hypot(v[1]); - // A 120 x 80 rectangle is its four corners, joined by straight edges. + // A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right + // corner clockwise as After Effects draws it. 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_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(); @@ -583,3 +584,86 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { 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)); } + +/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so +/// the editable path is the shape that was on screen. +#[test] +fn convert_to_bezier_honours_per_corner_roundness() { + 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); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + let per_corner = uid_of(&s, "perCorner"); + let top_left = uid_of(&s, "topLeftRoundness"); + let bottom_right = uid_of(&s, "bottomRightRoundness"); + for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] { + s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).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); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the converted path has the per-corner geometry"); +} + +/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven +/// Per-Corner checkbox and radius convert to the geometry on screen. +#[test] +fn convert_to_bezier_evaluates_corner_expressions() { + struct Corners; + impl effectcraft_render::ExprHost for Corners { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + prop: &effectcraft_project::Property, + value: &KV, + ) -> std::result::Result { + Ok(match prop.match_id.as_str() { + "perCorner" => KV::Bool(true), + "topLeftRoundness" => KV::Scalar(20.0), + _ => value.clone(), + }) + } + } + + let mut s = comp(); + s.expr = Some(Arc::new(Corners)); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] { + let u = uid_of(&s, id); + s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).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); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the expression-evaluated corner is converted"); +} + +/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a +/// shared merge key; one undo must revert the whole drag, not the last frame. +#[test] +fn free_transform_drag_undoes_in_one_step() { + let mut s = comp(); + let (l, m) = masked(&mut s); + s.state.selected_vertices.clear(); + let before = mask_path(&s, l, m); + for k in [90, 90, 90, 90] { + s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap(); + } + let after = mask_path(&s, l, m); + assert_ne!(after, before); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag"); +} diff --git a/crates/engine/src/tests/tail.rs b/crates/engine/src/tests/tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests/tail.rs +++ b/crates/engine/src/tests/tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches. #[test] fn numeric_string_resolves_as_id() { diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 17d0345d8..7a04ea155 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() { assert!(s.state.text_edit.is_none()); } +/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends +/// (#2226), as in After Effects — typing must not keep editing an unselected layer. +#[test] +fn duplicate_during_a_text_edit_ends_the_edit() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap(); + assert_ne!(copy, t, "the duplicate is created"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected"); + assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)"); + assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text"); + assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it"); +} + #[test] fn caret_style_applies_to_the_next_typed_text() { let (mut s, t) = session("ab"); @@ -260,25 +274,25 @@ fn clipboard_and_paste_formatting() { } #[test] -fn type_tool_layers_point_box_and_empty_cleanup() { +fn type_tool_layers_point_box_and_empty_layer_is_kept() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); - // Leaving an untouched Type-tool layer deletes it. + // Leaving an untouched Type-tool layer keeps it, as After Effects does. s.execute("text.endEdit", json!({})).unwrap(); - assert!(s.active_comp().unwrap().layers.is_empty()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); s.execute("text.insert", json!({"text": "Boxed"})).unwrap(); let d = doc(&s, b); assert_eq!(d.box_size, Some([300.0, 120.0])); assert_eq!(d.box_pos, [-150.0, -60.0]); - let l = s.active_comp().unwrap().layers[0].clone(); + let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone(); assert_eq!(l.name, "Boxed", "named after its text"); let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included"); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() { let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap(); assert_eq!(r["families"][0]["family"], "Noto Serif"); // The bundled face is Regular; the OS can also provide Bold, Italic, or other faces. - // Compare against the independent directory inventory rather than assuming an empty OS. - let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]); - expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone())); + // Model `add_source()` on the independent directory inventory: a system face whose family + // and primary style duplicate an earlier face is skipped, with its named instances. + let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]); + let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]); + for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) { + if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) { + expected_styles.insert(face.style.to_ascii_lowercase()); + expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase())); + } + } let styles: Vec = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect(); - assert_eq!(styles.iter().cloned().collect::>(), expected_styles); + // `families()` keeps the first spelling of a style that differs only in case. + assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::>(), expected_styles); assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates"); assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser"))); // The style menus offer a family's own styles. @@ -1202,7 +1224,7 @@ fn deselect_all_ends_the_open_edit() { } #[test] -fn deselect_all_discards_an_empty_type_tool_layer() { +fn deselect_all_keeps_an_empty_type_tool_layer() { let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!( @@ -1214,7 +1236,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects"); } diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index 07a48af1c..748f8a32f 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -1,6 +1,6 @@ //! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. -use effectcraft_project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer}; use effectcraft_render::three_d::View3D; use serde_json::json; @@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are +/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches +/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does. +#[test] +fn environment_lights_switch_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() + }; + let mut s = session(); + let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + toasts(&mut s); + let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert_eq!(r["advanced3d"], true); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // As with Composition Settings: the text layer gets Geometry Options. + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some()); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D"))); + // A second Environment light leaves the comp (and the user) alone. + s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert!(toasts(&mut s).is_empty()); + // Undoing the creations takes the lights and the switch back together. + s.undo(); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(layer_count(&s), 1); + // Changing a light to Environment switches too, and undoes with the type. + let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Other light types never switch it. + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + // Switching back to Classic 3D with an Environment light in the comp explains itself. + let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap(); + toasts(&mut s); + s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); + assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained"); + // Editing that Environment light (no type change) leaves the renderer the user chose. + s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch"); + assert!(toasts(&mut s).is_empty(), "and doesn't toast either"); +} + +/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every +/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no +/// Falloff, which an environment light shining from every direction has no use for (#2436). +#[test] +fn environment_light_takes_shadow_options_but_no_falloff() { + let mut s = session(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"] + .as_u64() + .unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(), + "an environment light has no Falloff" + ); + // The shadow values carry over when the type changes, both ways. + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0); + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again"); + s.undo(); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(), + "back to Spot: it has a Cone Angle again" + ); +} + #[test] fn three_d_switch_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 33dac7795..c912e0d87 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() { assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); } +/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index, +/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The +/// effect keeps its name; other properties, dimensions, insertion into an expression and match +/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the +/// references read the right values.) +#[test] +fn pick_whip_by_index_selects_effect_properties_by_their_index() { + let (mut s, l) = setup(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Color Control", "Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + // Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree. + let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone(); + let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1; + let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1; + let whip = |s: &mut Session, path: &str, target: Value, extra: Value| { + let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("prop.pickWhip", p).unwrap() + }; + let other = "thisComp.layer(\"Other\")"; + let undo = s.history.undo.len(); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})), + json!(format!("{other}.effect(\"Color Control\")(1)[0]")), + "a colour into one value: its first" + ); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})"); + assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested)); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})), + json!(format!("{other}.effect(\"Point Control\")(1)[1]")) + ); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})), + json!(format!("1 + {nested}")), + "inserted at the cursor" + ); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})), + json!(format!("{other}.transform.opacity")), + "only an effect's properties go by index" + ); + // Match names (Compact English off): the effect by its match name, its properties by index. + s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap(); + let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})); + assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick"); +} + /// #407: Reset without a value gives a property its own default (a Transform property's, an /// effect parameter's) besides removing its keyframes and expression. #[test] diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 9cfb94ecc..10c07c2ad 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -160,6 +160,37 @@ fn psd_imports_as_composition() { assert!(s.project.comp(cid).is_none()); } +/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that +/// composition; it isn't wrapped in another. +#[test] +fn psd_imported_as_composition_with_new_comp_is_the_new_comp() { + let path = tmp("newcomp.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count(); + assert_eq!(roots, 1, "no wrapper comp"); +} + +/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds +/// its composition once, not its folder too. +#[test] +fn psd_imported_as_composition_into_a_comp_adds_one_layer() { + let path = tmp("dropped.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let psd = r["comps"][0].as_u64().unwrap(); + let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers; + assert_eq!(layers.len(), 1, "{layers:?}"); + assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd)); +} + #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 5ed0848b9..1ff12237e 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() { fn masks_of_every_shape_tool_kind() { let mut s = comp(); let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); - for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] { + for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] { let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap(); let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); assert!(path.closed, "{kind}"); diff --git a/crates/engine/tests/blender_scene_oracle.py b/crates/engine/tests/blender_scene_oracle.py new file mode 100644 index 000000000..3d1cbeda7 --- /dev/null +++ b/crates/engine/tests/blender_scene_oracle.py @@ -0,0 +1,61 @@ +"""Public-API oracle for scripts generated by src/tests/blender.rs; no Blender source dependency.""" +import json +import math +import runpy +import sys + +import bpy +from bpy_extras.object_utils import world_to_camera_view +from mathutils import Quaternion, Vector + +path = sys.argv[sys.argv.index("--") + 1] +before = set(bpy.data.scenes) +exported = runpy.run_path(path, run_name="__main__") +scene = exported["EFFECTCRAFT_SCENE"] +data = exported["DATA"] +assert before.issubset(set(bpy.data.scenes)), "existing scenes were removed" +assert scene not in before +assert scene.frame_start == 101 and scene.frame_end == 103 +assert abs(scene.render.fps / scene.render.fps_base - 30000 / 1001) < 1e-4 +assert (scene.render.resolution_x, scene.render.resolution_y) == (640, 360) +camera = scene.camera +assert camera.animation_data.action is not None +assert camera.data.animation_data.action is not None +assert camera.data.sensor_fit == 'HORIZONTAL' +metadata = json.loads(bpy.data.texts[scene["effectcraft_metadata_text"]].as_string()) +assert metadata["solve"]["averageError"] == 0.12 +cloud = scene.objects["Solved Track Points"] +assert len(cloud.data.vertices) == 2 +assert cloud.hide_render +assert cloud.data.attributes["track_id"].data[0].value == 7 +assert any(obj.name.startswith("Solved Ground Plane") for obj in scene.objects) + +for i, sample in enumerate(data["frames"]): + scene.frame_set(sample["frame"]) + bpy.context.view_layer.update() + assert abs(camera.data.lens - sample["lens"]) < 1e-5 + assert abs(camera.data.dof.focus_distance - sample["focus"]) < 1e-5 + assert abs(camera.data.dof.aperture_fstop - sample["fstop"]) < 1e-5 + assert camera.data.dof.use_dof + assert (camera.location - Vector(sample["location"])).length < 1e-5 + raw = data["solve"]["frames"][i] + rot = Vector(raw["rot"]) + rotation = Quaternion(rot.normalized(), rot.length).to_matrix() if rot.length else Quaternion().to_matrix() + for vertex, point in zip(cloud.data.vertices, data["solve"]["points"]): + relative = rotation @ (Vector(point["pos"]) - Vector(raw["center"])) + expected = (raw["focal"] * relative.x / relative.z + 320, + raw["focal"] * relative.y / relative.z + 180) + projected = world_to_camera_view(scene, camera, vertex.co) + actual = (projected.x * 640, (1.0 - projected.y) * 360) + assert math.dist(actual, expected) < 0.002, (sample["frame"], actual, expected) + + if "Video not found" not in " ".join(data["warnings"]): + background = camera.data.background_images[0] + assert background.image.source == 'MOVIE' + assert tuple(background.image.size) == (640, 360) + user = background.image_user + actual_source = sample["frame"] - user.frame_start + user.frame_offset + assert actual_source == sample["sourceFrame"], (actual_source, sample["sourceFrame"]) + assert user.use_auto_refresh + +print("EFFECTCRAFT_BLENDER_ORACLE_OK") diff --git a/crates/engine/tests/it/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs index f9dd9ae3f..61440e273 100644 --- a/crates/engine/tests/it/parenting_3d_position.rs +++ b/crates/engine/tests/it/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 2ef6f5ad8..f2c185262 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-export" -description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF" +description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF" version.workspace = true edition.workspace = true license.workspace = true @@ -22,7 +22,7 @@ effectcraft-opusenc = { workspace = true } effectcraft-vp9enc = { workspace = true } effectcraft-hevcenc = { workspace = true } effectcraft-av1enc = { workspace = true } -image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] } +image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] } gif = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index e48bc4bb1..619d0a9d9 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -7,7 +7,7 @@ use std::io::Write; use effectcraft_project::Comp; use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile}; use effectcraft_time::{FrameRate, Tick}; -use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_isobmff::{Brand, ColorInfo, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use rayon::prelude::*; use crate::{Cx, ExportError, Report, Result, State, batch_size, wants_audio}; @@ -19,6 +19,17 @@ fn mux_err(e: impl std::fmt::Display) -> ExportError { ExportError::Io(e.to_string()) } +/// Tag a video [`SampleEntry`] with the BT.709 limited-range `colr` (nclx) box every video +/// encoder here outputs (`rgba_to_yuv420`, `rgba_to_yuv_10` and `rgba_to_yuv420_bits` all use +/// the 0.2126/0.7152/0.0722 luma and a 16–235 luma range). Without it a re-import falls back to +/// the container's resolution-based `default_matrix` — BT.601 below 1024×577 — and shifts the +/// colours (pure red came back as [232,0,1]). +pub(crate) fn tag_bt709_limited(entry: &mut SampleEntry) { + if let Some(v) = entry.video.as_mut() { + v.color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); + } +} + pub(crate) struct Packet { pub(crate) data: Vec, pub(crate) key: bool, @@ -110,7 +121,9 @@ impl VideoEncoder for H264 { let (sps, pps) = self.enc.sps_pps(); filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); - SampleEntry::avc(cfg, self.w as u16, self.h as u16) + let mut entry = SampleEntry::avc(cfg, self.w as u16, self.h as u16); + tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], index: u64) -> Result> { let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v)); diff --git a/crates/export/src/hevc_av1.rs b/crates/export/src/hevc_av1.rs index f42ccda24..c363dff35 100644 --- a/crates/export/src/hevc_av1.rs +++ b/crates/export/src/hevc_av1.rs @@ -92,7 +92,9 @@ impl Hevc { impl VideoEncoder for Hevc { fn sample_entry(&self) -> SampleEntry { let cfg = HevcConfig::parse(&self.enc.hvcc()).unwrap_or_default(); - SampleEntry::hevc(cfg, self.w as u16, self.h as u16) + let mut entry = SampleEntry::hevc(cfg, self.w as u16, self.h as u16); + crate::encode::tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let (w, h) = (self.w as usize, self.h as usize); @@ -148,7 +150,9 @@ impl Av1 { impl VideoEncoder for Av1 { fn sample_entry(&self) -> SampleEntry { let cfg = Av1Config::parse(&self.enc.av1c()).unwrap_or_default(); - SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16) + let mut entry = SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), self.w as u16, self.h as u16); + crate::encode::tag_bt709_limited(&mut entry); + entry } fn encode(&mut self, rgba: &[u8], _index: u64) -> Result> { let (w, h) = (self.w as usize, self.h as usize); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 7ea28b571..1b69a87bd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -7,6 +7,7 @@ //! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — | //! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — | +//! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — | //! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — | //! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | //! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) | @@ -335,19 +336,22 @@ fn write_still(cx: &Cx, file: &mut W, img: &I .write_image(&rgb, w, h, ExtendedColorType::Rgb8) .map_err(|e| ExportError::Encode(e.to_string())) } - OutputFormat::PngSequence | OutputFormat::TiffSequence => { + OutputFormat::PngSequence | OutputFormat::TiffSequence | OutputFormat::TargaSequence => { let px = cx.pixels(img, comp, channels, w, h); let (data, ct) = match channels { Channels::Rgba => (px, ExtendedColorType::Rgba8), Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8), Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8), }; - if fmt == OutputFormat::PngSequence { - image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) - } else { + match fmt { + OutputFormat::PngSequence => image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct), + // Uncompressed (the encoder's run-length compression is off), top-left origin, the + // alpha bits in the image descriptor: 8 for RGB + Alpha, 0 otherwise. + OutputFormat::TargaSequence => image::codecs::tga::TgaEncoder::new(file).disable_rle().write_image(&data, w, h, ct), // (the TIFF encoder needs Seek) - image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string())) + _ => image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct), } + .map_err(|e| ExportError::Encode(e.to_string())) } f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))), } @@ -372,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_ let end = (i + batch).min(st.total); let frames: Vec> = job .frames(comp, (i..end).collect(), |_, img| { - let mut px = job.pixels(&img, comp, job.output.channels, w, h); + let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false); gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) }) .await; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index ae52d3478..8e8c90561 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -4,10 +4,8 @@ use std::sync::Mutex; -use effectcraft_project::render_queue::{ - AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, -}; -use effectcraft_project::{Comp, Project}; +use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path}; +use effectcraft_project::{BitDepth, Comp, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -39,6 +37,57 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders +/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working +/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI +/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames. +pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT"; + +/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or +/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a +/// number or too large. +pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { + let v = var?; + if v.trim().is_empty() { + return None; + } + match v.trim().parse::() { + Ok(n) if n >= 1 => Some(n), + _ => { + log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped"); + None + } + } +} + +/// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for +/// the process's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Inflight { + pub gpu: Option, + pub cpu: Option, +} + +impl Inflight { + /// Frames rendered at once: the smaller cap while the GPU renders, and only the CPU cap once + /// it has stopped for good (the CPU fallback renders in parallel, #1511). `None`: uncapped. + pub fn renders(&self, gpu_live: bool) -> Option { + let gpu = self.gpu.filter(|_| gpu_live); + let n = match (gpu, self.cpu) { + (Some(g), Some(c)) => Some(g.min(c)), + (g, c) => g.or(c), + }; + n.map(|n| n.max(1)) + } + + /// Frames per window, rendered (`renders` at a time) and then encoded in parallel: with a CPU + /// cap at most that many, so finished images held before encoding count against it too; + /// with a GPU cap alone, one per thread (the images are in RAM, not VRAM). + pub fn window(&self, renders: usize) -> usize { + self.cpu.map_or(renders.max(rayon::current_num_threads()), |c| c.max(1)) + } +} + /// `render` then `encode` over the frames `ks`, results in `ks` order. /// /// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as @@ -55,38 +104,41 @@ pub(crate) fn map_frames( render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { - map_frames_while(ks, cap, || true, render, encode) + map_frames_while(ks, Inflight { gpu: cap, cpu: None }, || true, render, encode) } -/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped -/// for good, so every frame falls back to the CPU), the remaining frames render in parallel -/// like the uncapped CPU path instead of one chunk of `cap` at a time. +/// [`map_frames`] with the caps of `caps`, the GPU cap holding only while `gpu_live()`: once it +/// turns false (the GPU stopped for good, so every frame falls back to the CPU), the remaining +/// frames render under the CPU cap alone, in parallel when there is none, instead of one chunk +/// of the GPU cap at a time. A CPU cap also bounds each window ([`Inflight::window`]). pub(crate) fn map_frames_while( ks: Vec, - cap: Option, - capped: impl Fn() -> bool, + caps: Inflight, + gpu_live: impl Fn() -> bool, render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { use rayon::prelude::*; - let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else { + if caps.renders(gpu_live()).is_none() { return ks.into_par_iter().map(|k| encode(k, render(k))).collect(); - }; - let window = n.max(rayon::current_num_threads()); + } let mut out = Vec::with_capacity(ks.len()); - for (w, win) in ks.chunks(window).enumerate() { - if !capped() { - out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k)))); + let mut rest = &ks[..]; + while !rest.is_empty() { + let Some(n) = caps.renders(gpu_live()) else { + out.par_extend(rest.par_iter().map(|&k| encode(k, render(k)))); break; - } + }; + let (win, tail) = rest.split_at(caps.window(n).min(rest.len())); let mut imgs = Vec::with_capacity(win.len()); let mut i = 0; while i < win.len() { - let end = if capped() { (i + n).min(win.len()) } else { win.len() }; + let end = caps.renders(gpu_live()).map_or(win.len(), |n| (i + n).min(win.len())); imgs.par_extend(win[i..end].par_iter().map(|&k| render(k))); i = end; } out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img))); + rest = tail; } out } @@ -166,6 +218,9 @@ impl<'a> Cx<'a> { pub fn new(job: &'a Job<'a>) -> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); + // Render Settings ▸ Color Depth overrides the project's depth for the whole render (every + // layer and effect quantises at it), not just the finished frame (#2022). + project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); let cache = LayerCache::default(); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. @@ -186,7 +241,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -226,10 +281,10 @@ impl<'a> Cx<'a> { } return out; } - // The cap is for the GPU's memory: once it has stopped for good, frames render on the - // CPU in parallel. + // The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the + // CPU, in parallel or under the CPU cap (#1615), which holds throughout. let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) + map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f) } /// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the @@ -286,6 +341,11 @@ impl<'a> Cx<'a> { (self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref())) } + /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). + fn inflight(&self) -> Inflight { + Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + } + /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. pub fn frame(&self, comp: &Comp, i: u64) -> Image { effectcraft_render::passes::block_on(self.frame_async(comp, i)) @@ -301,14 +361,6 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } } }; - let mut img = img; - // Color Depth: renders run at the project's depth; another depth quantises the frame. - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current - && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels() - { - img.quantize(levels); - } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -325,12 +377,14 @@ impl<'a> Cx<'a> { return None; } let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None }; - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() { + // Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the + // accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't + // band (#2022). The project's depth already includes Render Settings ▸ Color Depth. + if self.project.settings.bit_depth != BitDepth::Bpc8 { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None; @@ -363,7 +417,7 @@ impl<'a> Cx<'a> { let gpu_live = || self.job.accel.is_none_or(|a| a.available()); map_frames_while( ks, - self.gpu_cap(), + self.inflight(), gpu_live, |k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)), |k, r| match r { @@ -376,13 +430,14 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); + // frame_size applies crop itself: use the original frame and the same scale as edges. + let (w, h) = om.frame_size(img.width, img.height, scale); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { return img; } - let (w, h) = om.frame_size(img.width, img.height, 1.0); if (w, h) == (img.width, img.height) { return img; } @@ -394,12 +449,24 @@ impl<'a> Cx<'a> { /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte. + /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After + /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients + /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to + /// dither, so its output stays byte for byte what it was. pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let dither = self.project.settings.bit_depth != BitDepth::Bpc8; + self.pixels_with(img, comp, channels, w, h, dither) + } + + /// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise). + pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec { let px = match channels { + Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background), Channels::Rgb => img.to_rgba8_over(comp.background), - Channels::Rgba => match self.job.output.alpha_mode { - AlphaMode::Straight => img.to_rgba8(), - AlphaMode::Premultiplied => premultiplied8(img), + Channels::Rgba => match (self.job.output.alpha_mode, dither) { + (AlphaMode::Straight, true) => img.to_rgba8_dithered(), + (AlphaMode::Straight, false) => img.to_rgba8(), + (AlphaMode::Premultiplied, _) => premultiplied8(img, dither), }, Channels::Alpha => img .data @@ -560,21 +627,42 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image { out } -/// Premultiplied 8-bit RGBA (colour matted with black). -fn premultiplied8(img: &Image) -> Vec { - img.data - .iter() - .flat_map(|p| { - let a = p[3].clamp(0.0, 1.0); - [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8]) - }) - .collect() +/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`. +fn premultiplied8(img: &Image, dither: bool) -> Vec { + let w = img.width.max(1) as usize; + let mut out = Vec::with_capacity(img.data.len() * 4); + for (i, p) in img.data.iter().enumerate() { + let a = p[3].clamp(0.0, 1.0); + let a8 = (a * 255.0 + 0.5) as u8; + let (x, y) = ((i % w) as u32, (i / w) as u32); + for c in 0..3 { + let v = p[c].clamp(0.0, a); + // Dither may round up past the rounded alpha; premultiplied colour never exceeds it. + out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 }); + } + out.push(a8); + } + out } #[cfg(test)] mod tests { use super::*; use rayon::prelude::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + /// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or + /// below the written alpha at every pixel. + #[test] + fn dithered_premultiplied_colour_never_exceeds_alpha() { + let v = 100.25 / 255.0; + let img = Image::filled(64, 64, [v, v, v, v]); + let px = premultiplied8(&img, true); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha"); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100)); + let plain = premultiplied8(&img, false); + assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100])); + } /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { @@ -607,7 +695,7 @@ mod tests { let out = pool.install(|| { map_frames_while( (0..24).collect(), - Some(1), + Inflight { gpu: Some(1), cpu: None }, || live.load(Ordering::SeqCst), |k| { if k == 1 { @@ -767,6 +855,103 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn cpu_inflight_is_off_unless_a_positive_number() { + assert_eq!(cpu_inflight(None), None); + assert_eq!(cpu_inflight(Some("2")), Some(2)); + assert_eq!(cpu_inflight(Some(" 4 ")), Some(4)); + for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] { + assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn cpu_cap_holds_and_combines_with_the_gpu_cap() { + // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. + let caps = |gpu, cpu| Inflight { gpu, cpu }; + for live in [true, false] { + assert_eq!(caps(None, None).renders(live), None); + assert_eq!(caps(None, Some(2)).renders(live), Some(2)); + } + assert_eq!(caps(Some(1), None).renders(true), Some(1)); + assert_eq!(caps(Some(1), None).renders(false), None); + assert_eq!(caps(Some(3), Some(2)).renders(true), Some(2)); + assert_eq!(caps(Some(1), Some(4)).renders(true), Some(1)); + // Review of #1615: once the GPU stops, the CPU cap applies, not the GPU's 1. + assert_eq!(caps(Some(1), Some(4)).renders(false), Some(4)); + // A CPU cap of 2 on 8 threads renders at most 2 frames at once. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| map_frames_while((0..16).collect(), caps(None, Some(2)), || true, |k| track(&now, &max, k), |_, k| k)); + assert_eq!(out, (0..16).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "{max:?} frames in flight"); + } + + /// Counts `k` in flight in `now`, records the most in `max`, and holds it for a moment. + fn track(now: &AtomicUsize, max: &AtomicUsize, k: u64) -> u64 { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + k + } + + #[test] + fn a_cpu_cap_bounds_the_images_waiting_to_encode() { + // Review of #1615: a window of one image per thread was rendered before any encoded, so + // a cap of 2 on 8 threads still held 8 finished images. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + for gpu in [None, Some(1)] { + let (held, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..20).collect(), + Inflight { gpu, cpu: Some(2) }, + || true, + |k| { + let c = held.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + k + }, + |k, img| { + held.fetch_sub(1, Ordering::SeqCst); + assert_eq!(k, img); + k + }, + ) + }); + assert_eq!(out, (0..20).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "gpu {gpu:?}: {max:?} images held"); + } + } + + #[test] + fn the_cpu_cap_takes_over_once_the_gpu_stops() { + // Review of #1615: with both caps, frames after the GPU stopped rendered one at a time. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..40).collect(), + Inflight { gpu: Some(1), cpu: Some(4) }, + || live.load(Ordering::SeqCst), + |k| { + if k == 1 { + live.store(false, Ordering::SeqCst); + } + if k < 4 { + return k; + } + track(&now, &max, k) + }, + |_, k| k, + ) + }); + assert_eq!(out, (0..40).collect::>()); + let max = max.load(Ordering::SeqCst); + assert!((2..=4).contains(&max), "{max} frames in flight after the GPU stopped"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); @@ -878,6 +1063,91 @@ mod tests { } } + /// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440. + #[test] + fn crop_resize_applies_margins_once() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let output = OutputModule { + crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() }, + resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() }, + ..Default::default() + }; + assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440)); + let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0); + assert_eq!((img.width, img.height), (1280, 1440)); + assert_eq!(img.data.len(), 1280 * 1440); + } + + /// Apply output-module geometry to a synthetic frame without rendering or writing files. + fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { + use effectcraft_project::render_queue::RenderSettings; + let project = Project::default(); + let settings = RenderSettings { resolution: scale, ..Default::default() }; + let job = Job { + project: &project, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output, + path: "unused.png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + Cx::new(&job).crop_resize(img) + } + + /// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities. + #[test] + fn crop_resize_dimensions_match_the_output_module() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() }; + let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() }; + let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() }; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality }; + for (crop, resize, scale, expected) in [ + (Crop::default(), Resize::default(), 1.0, (8, 6)), + (edges, Resize::default(), 1.0, (4, 6)), + (Crop::default(), resize(4), 1.0, (4, 3)), + (edges, resize(8), 1.0, (8, 12)), + (padding, resize(5), 1.0, (5, 4)), + (roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)), + (roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)), + (edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)), + ] { + let output = OutputModule { crop, resize, ..Default::default() }; + assert_eq!(output.frame_size(8, 6, scale), expected); + let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale); + assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}"); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + } + } + } + + /// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes. + #[test] + fn crop_resize_zero_and_extreme_sizes_stay_bounded() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + for (size, crop, width, height, expected) in [ + ((8, 6), Crop::default(), 0, 0, (1, 1)), + ((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)), + ((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)), + ((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)), + ((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)), + ] { + let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() }; + let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0); + assert_eq!((img.width, img.height), expected); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + assert!(img.data.iter().all(|p| *p == [0.0; 4])); + } + } + } + #[test] fn crop_and_pad() { let mut img = Image::new(4, 3); diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs new file mode 100644 index 000000000..3f99f1eb6 --- /dev/null +++ b/crates/export/tests/it/banding.rs @@ -0,0 +1,156 @@ +//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the +//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the +//! render itself. + +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::*; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 1024; +const H: u32 = 16; +/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px, +/// so plain rounding makes bands about 40 px wide. +const LO: f64 = 0.20; +const HI: f64 = 0.30; +/// Width of the column blocks whose mean is compared with the ideal ramp. +const WINDOW: u32 = 8; + +/// A grey horizontal ramp over a comp of the given project depth. +fn ramp_project(depth: BitDepth) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + comp.background = [0.0, 0.0, 0.0]; + let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect"); + let mut next = p.next_id; + let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + for (k, v) in [ + ("start", Value::Vec2([0.0, 0.0])), + ("end", Value::Vec2([W as f64, 0.0])), + ("startColor", Value::Color([LO, LO, LO, 1.0])), + ("endColor", Value::Color([HI, HI, HI, 1.0])), + ] { + group.get_mut(k).expect("ramp parameter").value = v; + } + layer.props.sub_mut("effects").expect("effects group").children.push(group.into()); + comp.layers.push(layer); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of +/// every row. +fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec> { + let files: Arc)>>> = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); + let settings = + RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() }; + let om = OutputModule::for_format(OutputFormat::PngSequence); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: "/banding/f_[#####].png", + sink: Some(&*sink), + options: JobOptions::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); + let files = files.lock().unwrap(); + let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG"); + let img = image::load_from_memory(png).expect("png").to_rgba8(); + (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect() +} + +/// The ideal ramp at column `x`, in 8-bit codes. +fn ideal(x: f64) -> f64 { + 255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64) +} + +/// Longest run of equal neighbours in the row: a band's width. +fn longest_run(row: &[u8]) -> usize { + let (mut best, mut run) = (1, 1); + for w in row.windows(2) { + run = if w[0] == w[1] { run + 1 } else { 1 }; + best = best.max(run); + } + best +} + +/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a +/// flat step, hundreds when the steps are dithered. +fn transitions(rows: &[Vec]) -> usize { + rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum() +} + +/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows +/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step +/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05). +fn worst_window_error(rows: &[Vec]) -> f64 { + (0..W / WINDOW) + .map(|i| { + let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize); + let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum(); + let mean = sum / (rows.len() * (x1 - x0)) as f64; + (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs() + }) + .fold(0.0, f64::max) +} + +#[test] +fn a_16_bpc_gradient_export_is_dithered() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + let rows = export_rows(&p, cid, ColorDepth::Current); + let (steps, err) = (transitions(&rows), worst_window_error(&rows)); + eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off + // by up to ~0.4 codes. + assert!(steps > 2000, "the steps survive: {steps} transitions"); + assert!(err < 0.25, "the local mean is off by {err} codes"); + // Dither never moves a value a whole code from the ideal ramp. + for row in &rows { + for (x, &v) in row.iter().enumerate() { + let want = ideal(x as f64); + assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}"); + } + } +} + +#[test] +fn the_export_is_the_same_every_time() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current)); +} + +#[test] +fn the_render_color_depth_reaches_the_render() { + // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither. + let (p, cid) = ramp_project(BitDepth::Bpc8); + let banded = export_rows(&p, cid, ColorDepth::Current); + assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export"); + assert!(transitions(&banded) < 16 * 30); + // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision + // until the final conversion, which dithers. + let deep = export_rows(&p, cid, ColorDepth::Bpc16); + let (steps, err) = (transitions(&deep), worst_window_error(&deep)); + eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}"); + // And 8 bpc on a 16 bpc project renders at 8 bits. + let (p16, cid16) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded); +} diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 59e670779..0f99a7b54 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_color::Label; -use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_raster::Image; @@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { assert!(a == c); } +/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes +/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022). +#[test] +fn renders_above_8_bpc_skip_the_planes() { + let d = out_dir("h264-gpu-yuv-16bpc"); + let (p, cid) = project(); + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() }; + let yuv = Fake::new(true, false, usize::MAX); + let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4")); + let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); + assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc"); + assert!(a == c); +} + /// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never /// through the blocking per-frame path the planes use. #[test] diff --git a/crates/export/tests/it/hevc_av1_roundtrip.rs b/crates/export/tests/it/hevc_av1_roundtrip.rs index 93a82719a..4022da1ca 100644 --- a/crates/export/tests/it/hevc_av1_roundtrip.rs +++ b/crates/export/tests/it/hevc_av1_roundtrip.rs @@ -123,7 +123,15 @@ fn ffprobe_video(path: &Path) -> Option { .output() .expect("ffprobe"); assert!(out.status.success() && out.stderr.is_empty(), "ffprobe rejected {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); - Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // An ffprobe whose build lacks this codec's decoder (distro "free" builds) only parses the + // container: pix_fmt and the counted frames come back unknown/N/A, describing the build + // rather than our output. Skip the oracle, as when ffprobe is absent. + if s.contains("|unknown|") || s.ends_with("|N/A") { + eprintln!("ffprobe cannot decode this codec here: skipping the oracle ({s})"); + return None; + } + Some(s) } /// Decode every frame with ffmpeg (no errors allowed) and check frames 0, 4, 5 and the last. @@ -140,7 +148,14 @@ fn ffmpeg_check(path: &Path, decoder: Option<&str>, tol: f32) { cmd.args(["-c:v", d]); } let out = cmd.arg("-i").arg(path).args(["-pix_fmt", "rgba"]).arg(dir.join("f%03d.png")).output().expect("ffmpeg"); - assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + // Same as ffprobe: an ffmpeg without this codec's decoder cannot judge our output, so the + // oracle skips instead of failing (the in-repo decode above already proved the frames). + if !out.status.success() && err.contains("no decoder found") { + eprintln!("ffmpeg cannot decode this codec here: skipping the oracle decode ({err})"); + return; + } + assert!(out.status.success() && err.is_empty(), "ffmpeg ({decoder:?}) failed on {}: {}", path.display(), err); for k in [0, 4, 5, FRAMES - 1] { let img = image::open(dir.join(format!("f{:03}.png", k + 1))).expect("ffmpeg frame").to_rgba8(); check_frame(&Image::from_rgba8(img.width(), img.height(), img.as_raw()), k, tol); diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 9b37d1809..88398a0e8 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod audio_sample_positions; +mod banding; mod h264_gpu_yuv; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] @@ -12,5 +13,6 @@ mod native_audio_sample_positions; mod opus_roundtrip; mod render_settings; mod roundtrip; +mod targa_sequence; mod uncompressed_mov; mod vp9_roundtrip; diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 5f1f8eb6b..2171bfe57 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -61,7 +61,7 @@ fn project() -> (Project, ItemId) { } box_.blend_mode = BlendMode::Screen; - comp.layers = vec![box_, ring, solid]; + comp.layers = vec![box_, ring, solid].into(); let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -94,7 +94,9 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_161, 10_466_446_817_305_548_344), "H.264 MP4"); + // FilmCraft 09aaf9b's encoder lists its settings in the first frame's user-data SEI (211 + // bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin. + assert_eq!((data.len(), fnv1a(&data)), (104_341, 17_655_680_090_264_218_914), "H.264 MP4"); } #[test] diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 465d8e5fe..0fd6d471b 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,31 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel. +/// Compare against crop-only output for padding and scaled ROIs with both resize qualities. +#[test] +fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + for (crop, scale, expected) in [ + (Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)), + (Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)), + ] { + let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) }; + let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) }; + let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0); + assert_eq!(reference.dimensions(), expected); + assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content"); + for quality in [ResizeQuality::Low, ResizeQuality::High] { + output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality }; + let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0); + assert_eq!(actual.dimensions(), expected); + assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed"); + } + } +} + #[test] fn alpha_only_and_premultiplied() { let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index 0fc747018..477191f64 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -23,6 +23,24 @@ const H: u32 = 48; const BG: [f32; 3] = [0.1, 0.1, 0.4]; const FRAMES: u64 = 12; +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free" +/// builds often omit `libx264` but ship an alternative (e.g. libopenh264), so pick the first +/// present by name rather than hard-coding one that may be absent. The oracle only needs *an* +/// H.264 encoder; the encoder identity is irrelevant to the assertion. +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + 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); @@ -260,6 +278,8 @@ fn image_sequences_roundtrip() { (OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb), (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb), (OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba), + (OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgb), + (OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgba), ] { let d = out_dir(&format!("seq-{ext}-{ch:?}")); let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) }; @@ -489,14 +509,18 @@ fn ffmpeg_audio(src: &Path) -> Vec { fn movies_carry_resampled_footage_audio() { let d = out_dir("audio-44k"); let clip = d.join("clip.mp4"); + let Some(enc) = h264_encoder() else { + eprintln!("ffmpeg has no H.264 encoder: skipping"); + return; + }; let made = Command::new("ffmpeg") .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=30000/1001:duration=3", "-f", "lavfi", "-i"]) - .args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .args(["aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) .arg(&clip) .status() .is_ok_and(|s| s.success()); if !made { - eprintln!("ffmpeg not found: skipping"); + eprintln!("ffmpeg could not make the H.264 fixture: skipping"); return; } let fps = FrameRate::new(30000, 1001); diff --git a/crates/export/tests/it/targa_sequence.rs b/crates/export/tests/it/targa_sequence.rs new file mode 100644 index 000000000..b496163d2 --- /dev/null +++ b/crates/export/tests/it/targa_sequence.rs @@ -0,0 +1,218 @@ +//! Targa sequence output: every file is an uncompressed TGA (Truevision TGA 2.0 specification: +//! an 18-byte header, BGR(A) pixels, top-left origin, the alpha bits in the image descriptor) +//! holding exactly the pixels the lossless PNG sequence of the same job writes, and EffectCraft's +//! own Targa import (#745) reads the frames back as the same picture. +//! +//! The comp is 7×4 @ 10 fps, 3 frames: frame 0 is the background alone, frames 1 and 2 add a +//! 2×2 solid in the top two rows (so a flipped picture shows), on a half pixel horizontally, so +//! its left and right columns carry partial alpha. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{AlphaMode, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path}; +use effectcraft_export::{Job, export}; +use effectcraft_keyframe::Value; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, build}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 7; +const H: u32 = 4; +const FRAMES: u64 = 3; + +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 +} + +fn project() -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0)); + comp.background = [0.1, 0.3, 0.6]; + let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 2, height: 2, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (2, 2), None); + l.props.prop_mut("transform/position").expect("position").value = Value::Vec3([3.5, 1.0, 0.0]); + l.in_point = comp.frame_rate.tick_of(1); + comp.layers.push(l); + let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Render the comp through `om` to `dir/f_[#####].`; the first frame's path. +fn render(p: &Project, cid: ItemId, om: &OutputModule, dir: &Path) -> String { + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let path = dir.join(format!("f_[#####].{}", om.format.extension())).to_string_lossy().to_string(); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &s, + output: om, + path: &path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!((r.frames, r.width, r.height), (FRAMES, W, H)); + r.path +} + +/// The numbered files of a render of `ext`: frames 0..FRAMES and nothing after. +fn frame_paths(dir: &Path, ext: &str) -> Vec { + let template = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string(); + assert!(!Path::new(&sequence_path(&template, FRAMES as i64)).exists(), "no frame after the last"); + (0..FRAMES as i64).map(|k| sequence_path(&template, k)).collect() +} + +/// A Targa file's header fields and its pixels as top-down RGBA bytes. +struct Tga { + image_type: u8, + width: u16, + height: u16, + depth: u8, + descriptor: u8, + rgba: Vec, +} + +fn read_tga(path: &str) -> Tga { + let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}")); + let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]); + assert_eq!((b[0], b[1]), (0, 0), "{path}: no image ID, no colour map"); + assert_eq!((u16_at(8), u16_at(10)), (0, 0), "{path}: X / Y origin"); + let (width, height, depth, descriptor) = (u16_at(12), u16_at(14), b[16], b[17]); + let bpp = usize::from(depth / 8); + // Uncompressed, without an image ID, colour map or 2.0 footer: header and pixels only. + assert_eq!(b.len(), 18 + usize::from(width) * usize::from(height) * bpp, "{path}: file size"); + let rgba = b[18..] + .chunks(bpp) + .flat_map(|p| match p { + [b, g, r, a] => [*r, *g, *b, *a], + [b, g, r] => [*r, *g, *b, 255], + [v] => [*v, *v, *v, 255], + _ => panic!("{path}: {depth}-bit pixels"), + }) + .collect(); + Tga { image_type: b[2], width, height, depth, descriptor, rgba } +} + +fn png_rgba(path: &str) -> Vec { + image::open(path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8().into_raw() +} + +#[test] +fn targa_sequence_writes_the_png_sequence_pixels() { + let (p, cid) = project(); + for (channels, alpha_mode, image_type, depth, descriptor) in [ + (Channels::Rgb, AlphaMode::Straight, 2, 24, 0x20), + (Channels::Rgba, AlphaMode::Straight, 2, 32, 0x28), + (Channels::Rgba, AlphaMode::Premultiplied, 2, 32, 0x28), + (Channels::Alpha, AlphaMode::Straight, 3, 8, 0x20), + ] { + let name = format!("tga-{channels:?}-{alpha_mode:?}"); + let tga_dir = out_dir(&name); + let png_dir = out_dir(&format!("{name}-png")); + let om = OutputModule { channels, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) }; + let first = render(&p, cid, &om, &tga_dir); + assert!(first.ends_with("f_00000.tga"), "{first}"); + render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, ..om.clone() }, &png_dir); + let mut frames = Vec::new(); + for (tga, png) in frame_paths(&tga_dir, "tga").iter().zip(frame_paths(&png_dir, "png")) { + let t = read_tga(tga); + assert_eq!((t.image_type, t.width, t.height, t.depth, t.descriptor), (image_type, W as u16, H as u16, depth, descriptor), "{tga}"); + assert_eq!(t.rgba, png_rgba(&png), "{tga} matches {png}"); + frames.push(t.rgba); + } + // Frame 0 is background only; frames 1 and 2 hold the solid, so the numbering follows + // the comp's frames. + let at = |f: &[u8], x: u32, y: u32| -> [u8; 4] { + let i = ((y * W + x) * 4) as usize; + [f[i], f[i + 1], f[i + 2], f[i + 3]] + }; + assert_ne!(at(&frames[0], 3, 1), at(&frames[1], 3, 1), "{name}"); + assert_eq!(frames[1], frames[2], "{name}"); + // Top-down rows: the solid is in the top half, the bottom rows are still background. + assert_eq!(at(&frames[1], 3, 3), at(&frames[0], 3, 3), "{name}: bottom row"); + match channels { + Channels::Rgb => { + assert_eq!(at(&frames[0], 0, 0)[3], 255); + assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid"); + } + Channels::Rgba => { + assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 0], "{name}: transparent background"); + assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid"); + let edge = at(&frames[1], 2, 1); + assert!(edge[3] > 0 && edge[3] < 255, "{name}: partial alpha at the solid's edge {edge:?}"); + if alpha_mode == AlphaMode::Straight { + // Unmatted from the renderer's premultiplied pixel: the solid's colour, within rounding. + assert!(edge[..3].iter().zip([230u8, 51, 26]).all(|(a, b)| a.abs_diff(b) <= 1), "{name}: straight colour {edge:?}"); + } else { + assert!(edge[0] < 230 && edge[0] <= edge[3], "{name}: premultiplied colour {edge:?}"); + } + } + Channels::Alpha => { + assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 255], "{name}: no matte yet"); + assert_eq!(at(&frames[1], 3, 1), [255, 255, 255, 255], "{name}: the solid's matte"); + } + } + } +} + +/// Numbered stills read back as an image sequence through the media layer (Targa: #745). +fn import_sequence(paths: &[String]) -> (Footage, MediaPool) { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let first = effectcraft_media::probe(&paths[0]).expect("probe our output"); + assert_eq!((first.kind, first.width, first.height), (FootageKind::Still, W, H)); + for p in paths { + let bytes: Arc<[u8]> = std::fs::read(p).expect("read").into(); + pool.add_bytes(p, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.to_vec(), ..first }; + footage.sync_sequence_duration(); + (footage, pool) +} + +#[test] +fn targa_sequence_round_trips_through_targa_import() { + let (p, cid) = project(); + for alpha_mode in [AlphaMode::Straight, AlphaMode::Premultiplied] { + let name = format!("tga-import-{alpha_mode:?}"); + let tga_dir = out_dir(&name); + let png_dir = out_dir(&format!("{name}-png")); + let om = OutputModule { channels: Channels::Rgba, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) }; + render(&p, cid, &om, &tga_dir); + // The reference: the same job as a straight-alpha PNG sequence (PNG's alpha is labelled). + render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, alpha_mode: AlphaMode::Straight, ..om.clone() }, &png_dir); + let (mut tga, tga_pool) = import_sequence(&frame_paths(&tga_dir, "tga")); + let (png, png_pool) = import_sequence(&frame_paths(&png_dir, "png")); + assert_eq!(tga.codec, "TGA"); + assert_eq!(tga.sequence_frames(), FRAMES as i64); + // Targa doesn't label its alpha: import guesses straight, and Interpret Footage sets + // premultiplied for a premultiplied render. + assert_eq!(tga.alpha, effectcraft_project::AlphaMode::Straight); + if alpha_mode == AlphaMode::Premultiplied { + tga.alpha = effectcraft_project::AlphaMode::Premultiplied; + } + // Premultiplying to 8 bits rounds partially transparent colour. + let tol = if alpha_mode == AlphaMode::Straight { 0.0 } else { 1.0 / 255.0 }; + for k in 0..FRAMES as i64 { + let a = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(k)).expect("decode Targa"); + let b = png_pool.frame_at(&png, png.frame_rate.tick_of(k)).expect("decode PNG"); + assert_eq!((a.width, a.height), (W, H)); + for (x, y) in a.data.iter().zip(&b.data) { + assert!(x.iter().zip(y).all(|(x, y)| (x - y).abs() <= tol), "{name} frame {k}: {x:?} != {y:?}"); + } + } + let opaque = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(1)).expect("decode"); + assert_eq!(opaque.data[opaque.idx(0, 0)][3], 0.0, "{name}: the background stays transparent"); + assert_eq!(opaque.data[opaque.idx(3, 1)][3], 1.0, "{name}: the solid is opaque"); + } +} diff --git a/crates/export/tests/it/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs index e7c2b0346..1432e3892 100644 --- a/crates/export/tests/it/vp9_roundtrip.rs +++ b/crates/export/tests/it/vp9_roundtrip.rs @@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec, Vec, Vec) { } fn decode(frame: &[u8]) -> filmcraft_vp9::Picture { - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let mut pics = d.decode(frame, 0).expect("decode"); assert_eq!(pics.len(), 1); pics.remove(0) @@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { let (w, h) = (cfg.width as usize, cfg.height as usize); let mut e = Vp9Encoder::new(cfg); - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let (mut bytes, mut db) = (0usize, 0.0); for t in 0..n { let (y, u, v) = moving(w, h, t, kind); @@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() { } let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") }; // The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg. - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); for (t, (f, r)) in frames.iter().zip(&recon).enumerate() { let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}")); assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}"); diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index a25fbc4f0..fa2993f97 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -10,7 +10,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; -use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_render::EvalCtx; use effectcraft_time::Tick; /// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). @@ -299,7 +299,17 @@ impl<'a> Resolver<'a> { } fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; - Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage }) + let same_comp = c == self.ctx.comp_id.0; + Some(EvalCtx { + project: self.ctx.project, + comp_id: ItemId(c), + comp, + time: Tick::from_seconds_f64(t), + expr: self.ctx.expr, + footage: self.ctx.footage, + property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, + media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { let layer = self.layer(c, l)?; @@ -372,7 +382,7 @@ impl<'a> Resolver<'a> { Req::LayerInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let (mut w, mut h) = source_size(project, l); + let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l); if w == 0 || h == 0 { (w, h) = (c.width, c.height); } @@ -467,7 +477,7 @@ impl<'a> Resolver<'a> { } } _ => { - let (w, h) = source_size(project, l); + let (w, h) = ctx.source_size(l); [0.0, 0.0, w as f64, h as f64] } }; @@ -501,7 +511,12 @@ impl<'a> Resolver<'a> { Req::SourceInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let it = project.item(l.source.item()?)?; + let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?; + let item = match &l.source { + LayerSource::Footage { item } => ctx.media_item(l, *item), + _ => l.source.item()?, + }; + let it = project.item(item)?; let (kind, aspect, frame, duration) = match &it.kind { ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..9e78d250f 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -17,7 +17,8 @@ //! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, //! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the //! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ -//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`), +//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have. //! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read @@ -162,7 +163,16 @@ fn evaluate_out( return Err("Error: expressions reference each other too deeply (circular reference?)".into()); } budget_left()?; - let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage }; + let ctx = EvalCtx { + project: ctx.project, + comp_id: ctx.comp_id, + comp: ctx.comp, + time: ctx.time, + expr: Some(host), + footage: ctx.footage, + property_overrides: ctx.property_overrides, + media_overrides: ctx.media_overrides, + }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 6611d36aa..3196ce5a3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { __seed = 0; __timeless = false; __projectInfo = null; + __layersTrim(); } function __finish(r) { @@ -44,7 +45,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (typeof r === 'function' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -207,6 +208,60 @@ function ease() { return __interp(__ease, arguments); } function easeIn() { return __interp(__easeIn, arguments); } function easeOut() { return __interp(__easeOut, arguments); } +// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1 +// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection +// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four +// finite numbers is linear, never an error. +function __cubicBezier(curve) { + var x1, y1, x2, y2; + if (Array.isArray(curve) && curve.length === 4) { + x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; + } + if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || + !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return __lin; + } + x1 = Math.max(0, Math.min(1, x1)); + x2 = Math.max(0, Math.min(1, x2)); + function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } + function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } + function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } + return function (x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var u = x; + for (var i = 0; i < 8; i++) { + var f = bx(u) - x; + var d = dbx(u); + if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); + var n = u - f / d; + if (!(n >= 0 && n <= 1)) break; + u = n; + } + var lo = 0, hi = 1; + for (var j = 0; j < 60; j++) { + var mid = (lo + hi) / 2; + if (bx(mid) < x) lo = mid; else hi = mid; + } + return by((lo + hi) / 2); + }; +} + +// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is +// the sixth / fourth argument. Read it by position, not from the end: when it is missing or +// unusable, the interpolation falls back to linear without consuming the last value. +function bezier() { + var a = arguments, n = a.length; + if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]); + if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]); + if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]); + return __interp(__lin, [a[0], a[1], a[2]]); +} + // --------------------------------------------------------------------------------------------- // Random numbers and noise. @@ -434,7 +489,7 @@ function __ancestor(c, l, path, countUp) { if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); - if (info === 'layer') return new Layer(c, l); + if (info === 'layer') return __layer(c, l); if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); return __mkGroup(c, l, info); } @@ -464,10 +519,10 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); - return new Layer(this.__c, id); + return __layer(this.__c, id); }, toString: function () { return '[object Comp]'; }, }; @@ -542,7 +597,55 @@ var thisProject = { // Layer ----------------------------------------------------------------------------------------- -function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } +// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's +// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English +// off). Callable like a group (`__mkGroup`), with the Layer methods. +function Layer(c, l) { + var f = function (k) { return f.__group(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Layer.prototype); + // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete + // a field or leave its own on it. Its info is private to `__info`, which reads it again in an + // evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so + // freezing the layer can't keep its info either. + var info = null, read = null; + Object.defineProperty(f, '__c', { value: c }); + Object.defineProperty(f, '__l', { value: l }); + Object.defineProperty(f, '__info', { + get: function () { + if (read !== __S) { info = __h(__LINFO, c, l); read = __S; } + return info; + }, + }); + Object.preventExtensions(f); + return f; +} + +// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain +// object, so it is kept, and its info (`__info`) is read again in each evaluation. +// The kept layers are out of an expression's reach (their maps have no prototype, so a change to +// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once +// `max` are kept, the layers after them aren't, and the next evaluation starts over +// (`__layersTrim`, so a layer stays the same object for a whole evaluation). +var __layer, __layersTrim; +(function () { + var layers = Object.create(null), kept = 0, max = 4096; + __layer = function (c, l) { + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = layers[c], f = m && m[l]; + if (f) return f; + f = new Layer(c, l); + if (kept < max) { + (m || (layers[c] = Object.create(null)))[l] = f; + kept++; + } + return f; + }; + __layersTrim = function () { + if (kept >= max) { layers = Object.create(null); kept = 0; } + }; +})(); function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -566,7 +669,6 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, @@ -578,7 +680,7 @@ Layer.prototype = { get parent() { var p = this.__info[7]; if (p === null) __err('Layer "' + this.name + '" has no parent'); - return new Layer(this.__c, p); + return __layer(this.__c, p); }, get hasVideo() { return this.__info[8]; }, get enabled() { return this.__info[11]; }, @@ -670,14 +772,17 @@ var __groupHandler = { }, }; -function __mkGroup(c, l, info) { - var f = function (k) { return f.__get(k); }; - delete f.name; - delete f.length; - Object.setPrototypeOf(f, __GroupProto); - f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; - return new Proxy(f, __groupHandler); -} +// `Proxy` is private to the object model (see "Stack safety" at the end). +var __mkGroup = (function (P) { + return function (c, l, info) { + var f = function (k) { return f.__get(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, __GroupProto); + f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; + return new P(f, __groupHandler); + }; +})(Proxy); // Properties ------------------------------------------------------------------------------------ @@ -747,7 +852,7 @@ Prop.prototype = { propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; - if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'layer') return v === null ? null : __layer(this.__c, v); if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); return v; }, @@ -777,7 +882,7 @@ Prop.prototype = { for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); }, - __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + __seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; }, wiggle: function (freq, amp, octaves, ampMult, t) { if (octaves === undefined) octaves = 1; if (ampMult === undefined) ampMult = 0.5; @@ -846,15 +951,18 @@ var __propHandler = { var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; - // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). - if (x?.__isGroup) return x; + // Property groups and layers are callable: hand them over as they are, not bound like methods + // (#809). + if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; -function __mkProp(c, l, path) { - return new Proxy(new Prop(c, l, path), __propHandler); -} +var __mkProp = (function (P) { + return function (c, l, path) { + return new P(new Prop(c, l, path), __propHandler); + }; +})(Proxy); // Text styles (the Source Text style API) ------------------------------------------------------ // A TextStyle is the source document (as JSON from the host), the character index it reads its @@ -1023,18 +1131,18 @@ Markers.prototype = { // --------------------------------------------------------------------------------------------- // Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're // accessors on the global object; assigning one (`var width = 10`) shadows it for the current -// evaluation only. +// evaluation only. They can't be deleted or redefined, so an expression can't take one away from +// the evaluations after it. function __global(name, get) { Object.defineProperty(globalThis, name, { - configurable: true, get: function () { return name in __S.vars ? __S.vars[name] : get(); }, set: function (v) { __S.vars[name] = v; }, }); } __global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); -__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); }); __global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); ['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { @@ -1069,3 +1177,121 @@ function velocityAtTime(t) { return thisProperty.velocityAtTime(t); } function speedAtTime(t) { return thisProperty.speedAtTime(t); } function key(n) { return thisProperty.key(n); } function nearestKey(t) { return thisProperty.nearestKey(t); } + +// --------------------------------------------------------------------------------------------- +// Stack safety (#2124, #2125). A stack overflow aborts the whole app, so nothing a script does +// may nest native code without bound. The expression text is checked for nesting before it is +// compiled; source handed to `eval` and the function constructors at run time is checked by +// `__nest`. The natives that walk nested values recursively (`join`, which `toString` and string +// conversion use, `toLocaleString`, `JSON.stringify`, `flat`) call `__stack` once per level, +// which throws once the run has used its share of the stack. `Proxy` (chains of proxies nest +// without any script frame) and `Reflect` are not available to expressions. +(function () { + var nest = __nest, stack = __stack, apply = Reflect.apply, construct = Reflect.construct; + var define = Object.defineProperty, describe = Object.getOwnPropertyDescriptor, proto = Object.getPrototypeOf; + // Replace `obj[name]` keeping its attributes. + function replace(obj, name, f) { + var d = describe(obj, name); + define(obj, name, { value: f, writable: d.writable, enumerable: d.enumerable, configurable: true }); + } + function guard(obj, name) { + var f = obj[name]; + replace(obj, name, function () { stack(); return apply(f, this, arguments); }); + } + + // Indirect on purpose: a wrapper can't make a direct eval, so `eval` sees globals and `var`s + // but not `let`/`const` locals of the expression. Keeping direct eval would mean leaving it + // unchecked; expressions practically never need their locals inside eval. + var evalJs = eval; + eval = function (x) { + if (typeof x === 'string') { + stack(); + nest(x); + } + return evalJs(x); + }; + // `Function` and the generator / async variants (reachable as `(function* () {}).constructor`). + function dynamic(F) { + var w = function () { + var args = []; + for (var i = 0; i < arguments.length; i++) args.push(String(arguments[i])); + stack(); + nest(args.slice(0, -1).join(',')); + nest(args.length ? args[args.length - 1] : ''); + return construct(F, args); + }; + define(w, 'name', { value: F.name, configurable: true }); + w.prototype = F.prototype; + replace(F.prototype, 'constructor', w); + return w; + } + Function = dynamic(Function); + dynamic(proto(function* () {}).constructor); + dynamic(proto(async function () {}).constructor); + dynamic(proto(async function* () {}).constructor); + + guard(Array.prototype, 'join'); + guard(Array.prototype, 'toLocaleString'); + var flat = Array.prototype.flat; + replace(Array.prototype, 'flat', function (depth) { + stack(); + var d = depth === undefined ? 1 : Number(depth); + return apply(flat, this, [d > 64 ? 64 : d]); + }); + + // A replacer function sees every value before it is serialized: the per-level check. An array + // replacer (a list of keys) becomes one that copies each object's listed keys, in list order. + // Each source object gets one copy per call, so a cycle in the source is a cycle of copies and + // the native check reports it (TypeError); an object shared without a cycle is still fine. + var stringify = JSON.stringify, MapJs = Map; + replace(JSON, 'stringify', function (value, replacer, space) { + var fn = typeof replacer === 'function' ? replacer : null; + var keys = null, copies = new MapJs(); + if (Array.isArray(replacer)) { + keys = []; + for (var i = 0; i < replacer.length; i++) { + var k = replacer[i]; + if (typeof k === 'number' || k instanceof Number || k instanceof String) k = String(k); + if (typeof k === 'string' && keys.indexOf(k) < 0) keys.push(k); + } + } + return apply(stringify, JSON, [value, function (key, v) { + stack(); + if (fn !== null) v = apply(fn, this, [key, v]); + if (keys !== null && v !== null && typeof v === 'object' && !Array.isArray(v) && + !(v instanceof Number || v instanceof String || v instanceof Boolean)) { + var o = copies.get(v); + if (o === undefined) { + o = {}; + for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + copies.set(v, o); + } + v = o; + } + return v; + }, space]); + }); + + delete globalThis.Proxy; + delete globalThis.Reflect; +})(); + +// --------------------------------------------------------------------------------------------- +// The runtime runs this file once and keeps it for the thread's later evaluations, so an +// expression can't change the object model or the internals: the prototypes are frozen, and the +// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is +// set again by `__begin` in each evaluation. + +[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) { + Object.freeze(f.prototype); + Object.freeze(f); +}); +[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze); +(function () { + var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true }; + var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true }; + Object.getOwnPropertyNames(globalThis).forEach(function (k) { + if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return; + if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); + }); +})(); diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 35c373bb4..34b52439b 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -805,6 +805,12 @@ pub fn check_nesting(src: &str) -> Result<(), String> { Ok(()) } +/// An error when plain JavaScript `src` (not rewritten: what `eval` and `Function` get at run +/// time) nests deeper than [`MAX_NESTING`] levels. +pub fn check_plain_nesting(src: &str) -> Result<(), String> { + if too_nested(&tokenize(src).0) { Err(too_deep()) } else { Ok(()) } +} + fn too_deep() -> String { format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)") } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 53c2960ba..84bfcdf3c 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -51,6 +51,8 @@ thread_local! { pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; /// Script runs since the outermost evaluation on this thread began (nested ones included). pub static RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Stack address where the current script run began (0: none; see [`native_stack`]). + static STACK_BASE: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[cfg(not(target_arch = "wasm32"))] @@ -232,17 +234,54 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + +/// `__stack()`: throw once the current run has used [`NATIVE_STACK`] (values nested so deeply +/// would overflow the stack, which aborts the app). +fn native_stack(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + let base = STACK_BASE.with(|b| b.get()); + if base != 0 && base.abs_diff(stack_here()) > NATIVE_STACK { + return Err(JsNativeError::range().with_message("value is nested too deeply").into()); + } + Ok(JsValue::undefined()) +} + +/// `__nest(src)`: throw when source handed to `eval` / `Function` at run time nests deeper than +/// the parser's stack allows (the expression text itself is checked before it is compiled). +fn native_nesting(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let src = string(args, 0, ctx)?; + crate::rewrite::check_plain_nesting(&src).map_err(|e| JsNativeError::range().with_message(e.trim_start_matches("Error: ").to_string()))?; + Ok(JsValue::undefined()) +} + impl Runtime { fn new() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ + (js_string!("__stack"), 0, native_stack), (js_string!("__h"), 6, native_host), (js_string!("__wig"), 8, native_wiggle), (js_string!("__noise"), 3, native_noise), (js_string!("__rnd"), 6, native_random), + (js_string!("__nest"), 1, native_nesting), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; @@ -361,6 +400,9 @@ pub fn out_of_time() -> bool { } } +/// Levels of nested arrays a script result keeps. +const MAX_DETACH: usize = 4; + /// Copy a JS value out of the heap. fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { if v.is_undefined() { @@ -380,7 +422,7 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { } let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX); if let Some(o) = v.as_object() { - if o.is_array() && depth < 4 { + if o.is_array() && depth < MAX_DETACH { let arr = JsArray::from_object(o).map_err(err)?; let n = arr.length(ctx).map_err(err)?.min(1 << 16); let mut out = Vec::with_capacity(n as usize); @@ -408,6 +450,11 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { return Ok(Out::Path { points, ins, outs, closed }); } } + // Below the levels detached above, an object's text could be its whole nested content + // (`String([[[…]]])`), converted by natives that recurse once per level. + if depth >= MAX_DETACH && v.is_object() { + return Ok(Out::Object("[object]".into())); + } Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default())) } @@ -484,6 +531,8 @@ fn position_line(msg: &str) -> Option { /// `__begin` with memoized answers, so nothing carries over. A script that really overflows the /// stack fails again on the fresh context and reports the error. pub fn run(text: &str, b: &Begin) -> Run { + // Runs don't nest (other expressions are evaluated between runs), so one base suffices. + STACK_BASE.with(|s| s.set(stack_here())); RUNTIME.with(|r| { let mut r = r.borrow_mut(); for fresh in [false, true] { @@ -628,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> let p0 = src.para(0); d.set_paras(vec![p0; d.para_count()]); d.stroke_over_fill = src.stroke_over_fill; + d.line_join = src.line_join; } } for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..57d491de5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1,5 +1,6 @@ use std::time::Instant; +mod deep_nesting; mod fuzz; use effectcraft_color::Label; @@ -76,7 +77,7 @@ fn fx() -> Fx { let ids = (a.id, b.id, title.id, shape.id); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![a, b, title, shape]; + c.layers = vec![a, b, title, shape].into(); c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); @@ -313,8 +314,78 @@ fn interpolation_with_arrays() { assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_curves() { + let f = fx(); + // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. + assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); + // Symmetric ease-in-out: slower at both ends, faster in the middle. + let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); + // Control points on the diagonal are linear. + assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); + // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. + assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); + assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); + assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); +} +#[test] +fn cubic_bezier_short_form_arrays_and_fallbacks() { + let f = fx(); + // Short form: no tMin / tMax. + assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); + assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); + // y outside 0..1 overshoots, as CSS allows. + let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); + assert!(over > 100.0, "overshoot: {over}"); + // A curve that is not four finite numbers falls back to linear instead of throwing. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // A missing or unusable curve must not consume the last value: read the curve by position. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); + let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); + // x control points outside 0..1 are clamped, as CSS requires. + let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); + assert!(v.is_finite(), "clamped curve: {v}"); +} + +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} + +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); @@ -907,6 +978,49 @@ fn source_rect_at_time() { assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } +#[test] +fn source_dimensions_follow_media_replacement() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = |p: &mut Project, name: &str, width, height| { + p.add_item( + name, + Label::SeaFoam, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: name.into(), + kind: effectcraft_project::FootageKind::Still, + width, + height, + has_video: true, + ..Default::default() + }), + ) + }; + let original = footage(&mut p, "original.png", 100, 80); + let replacement = footage(&mut p, "replacement.png", 30, 40); + let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let media = effectcraft_render::MediaOverride::new(lid, replacement); + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[media], + }; + assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); +} + // ---- colour ----------------------------------------------------------------------------------- #[test] @@ -985,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); + assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] @@ -1114,7 +1230,16 @@ fn text_expression_selector_values() { let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); - let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); @@ -1370,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() { } } +/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another +/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact +/// English off (they failed: "not a callable function"). It is still a layer. +#[test] +fn layers_are_callable_property_groups() { + let f = fx(); + assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); + // Its own name, index and methods, and a layer as the result still reads as its index. + assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); + assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); +} + +/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project +/// afresh in each, and an expression's own fields on it don't reach the next one. +#[test] +fn a_kept_layer_reads_the_project_again() { + let mut f = fx(); + let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; + assert_close!(f.num(first, 0.0), 21.0); + f.layer_mut(f.b).name = "Bee".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); + assert_close!(f.num("thisLayer.index", 0.0), 2.0); + let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); + assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); + f.layer_mut(f.a).name = "Ay".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); +} + +/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more +/// layers than are kept still sees one object for each layer it had kept, and the evaluations +/// after it, which start over, read the right layers. +#[test] +fn the_kept_layers_are_bounded() { + let f = fx(); + let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; + let many = + format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); + assert_close!(f.num(&many, 0.0), 11.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); +} + +/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under +/// the comp's id, before any of its layers is kept, doesn't stop them being kept. +#[test] +fn the_kept_layers_dont_go_through_object_prototype() { + let f = fx(); + let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); + assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); +} + +/// The prelude is kept from one evaluation to the next, so an expression can't change its object +/// model or internals for the ones after it. +#[test] +fn an_expression_cant_change_the_prelude_for_the_next() { + let f = fx(); + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; + let before = f.ev(f.a, ROT, read, 0.5).unwrap(); + let attacks = [ + "Comp.prototype.layer = function () { return 0; }", + "Object.getPrototypeOf(thisLayer).index = 0", + "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", + "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", + "Comp = function () {}", + "Prop = function () {}", + "thisProject = null", + "__h = function () { return null; }", + "__layer = function () { return null; }", + "__child = function () {}", + "delete __mkProp", + "delete thisLayer", + "delete thisComp", + "delete width", + "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", + ]; + for a in attacks { + let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); + assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); + } +} + mod property_group; diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs new file mode 100644 index 000000000..ae78c5b20 --- /dev/null +++ b/crates/expr/src/tests/deep_nesting.rs @@ -0,0 +1,130 @@ +//! Deep nesting the expression text check can't see (#2124, #2125): source handed to `eval` / +//! `Function` at run time, and deeply nested values that boa's natives (`join`, `JSON.stringify`, +//! proxies…) walk recursively. Each used to overflow the stack, which aborts the whole app +//! (`catch_unwind` can't catch it); now each is an expression error. + +use super::*; + +/// The render threads' stack size (rayon's default): what the app gets. +const RENDER_STACK: usize = 2 << 20; + +/// Evaluate `text` on the title's source text (any result converts) on a render-sized stack. +fn eval_small_stack(text: &str) -> Result { + eval_on_stack(text, RENDER_STACK) +} + +/// Evaluate `text` on the title's source text on a thread with `size` bytes of stack. +fn eval_on_stack(text: &str, size: usize) -> Result { + let text = text.to_string(); + std::thread::Builder::new() + .stack_size(size) + .spawn(move || { + let f = fx(); + f.ev(f.title, "text/sourceText", &text, 0.0) + }) + .unwrap() + .join() + .unwrap() +} + +fn text_of(r: Result) -> String { + match r { + Ok(Value::Text(d)) => d.text, + other => panic!("not text: {other:?}"), + } +} + +/// A 100 000-deep array: `var a = []; … a = [a]` in a loop (under the loop limit). +const DEEP_ARRAY: &str = "var a = []; for (var i = 0; i < 100000; i++) a = [a];"; + +#[test] +fn eval_and_function_constructors_check_nesting() { + let deep = r#""(".repeat(1000) + "1" + ")".repeat(1000)"#; + for call in [ + format!("eval({deep})"), + format!("Function({deep})()"), + format!("new Function('a', {deep})(1)"), + format!("Function('a = ' + {deep}, 'return a')()"), + "Function.apply(null, '('.repeat(1000).split('').concat(['1']))".to_string(), + format!("(function () {{}}).constructor({deep})()"), + format!("Object.getPrototypeOf(function* () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function* () {{}}).constructor({deep})"), + ] { + let e = eval_small_stack(&call).expect_err(&call); + assert!(e.contains("nested too deeply"), "{call}: {e}"); + } +} + +#[test] +fn eval_and_function_still_work() { + let f = fx(); + assert_eq!(f.num("eval('1 + 2')", 0.0), 3.0); + assert_eq!(f.num("eval('[1, [2, (3)]]')[1][1]", 0.0), 3.0); + assert_eq!(f.num("new Function('a', 'b', 'return a * b')(3, 4)", 0.0), 12.0); + assert_eq!(f.num("Function('return 5')()", 0.0), 5.0); + assert_eq!(f.num("(function () {}).constructor('return 6')()", 0.0), 6.0); + assert_eq!(f.num("(function () {}) instanceof Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("(function () {}).constructor === Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("Object.getPrototypeOf(function* () { yield 7; }).constructor('yield 7')().next().value", 0.0), 7.0); +} + +#[test] +fn deep_array_result_is_an_error_or_text_not_a_crash() { + // The result itself: detaching it used to `toString` it past depth 4. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} a")); + // Explicit conversions inside the script. + for tail in ["a.join()", "a.toString()", "String(a)", "a + ''", "`${a}`", "a.toLocaleString()", "JSON.stringify(a)", "JSON.stringify({ a: a })"] { + let text = format!("{DEEP_ARRAY} {tail}"); + let e = eval_small_stack(&text).expect_err(&text); + assert!(e.contains("nested too deeply"), "{text}: {e}"); + } + // A deep array as a text style value goes through JSON.stringify in the prelude. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} text.sourceText.style.setFontSize(a)")); +} + +#[test] +fn deep_proxy_chain_is_an_error_not_a_crash() { + let text = "var p = {}; for (var i = 0; i < 100000; i++) p = new Proxy(p, {}); p.x"; + let e = eval_small_stack(text).expect_err(text); + assert!(e.contains("Proxy") && e.contains("not defined"), "{text}: {e}"); + // Neither is available to expressions; the object model's own proxies still work. + let f = fx(); + assert_eq!(f.text("typeof Proxy + ' ' + typeof Reflect", 0.0), "undefined undefined"); + assert_eq!(f.num("thisComp.layer('A').effect('Slider Control')('Slider')", 0.0), 42.0); +} + +#[test] +fn nested_values_still_convert() { + let f = fx(); + assert_eq!(f.text("[[1, 2], [3, [4, 5]]].join(';')", 0.0), "1,2;3,4,5"); + assert_eq!(f.text("String([[1, 2], 3])", 0.0), "1,2,3"); + assert_eq!(f.text("JSON.stringify({ a: [1, { b: [2] }] })", 0.0), r#"{"a":[1,{"b":[2]}]}"#); + assert_eq!(f.text("JSON.stringify({ a: 1, b: 2 }, ['b'])", 0.0), r#"{"b":2}"#); + assert_eq!(f.text("JSON.stringify([1, [2]], null, 1)", 0.0), "[\n 1,\n [\n 2\n ]\n]"); + assert_eq!(f.text("JSON.stringify({ a: 1 }, function (k, v) { return k === 'a' ? v + 1 : v; })", 0.0), r#"{"a":2}"#); + // Nested deeper than the detached levels: still converts to its text. + assert_eq!(text_of(eval_small_stack("[[[[[[1, 2]]]]]]")), ""); + assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1"); + assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0); +} + +/// The main thread runs commands and syntax checks: 1 MiB on Windows. Half of it, for the +/// callers above the evaluation, must still turn the nested-value triggers into errors. +#[test] +fn nested_values_are_an_error_on_half_the_main_thread() { + for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] { + let text = format!("{DEEP_ARRAY} {tail}"); + let e = eval_on_stack(&text, 512 << 10).expect_err(&text); + assert!(e.contains("nested too deeply"), "{tail}: {e}"); + } +} + +#[test] +fn stringify_with_a_key_list_reports_cycles_and_allows_shared_objects() { + let f = fx(); + let cyclic = f.text("var o = {a: 1}; o.self = o; try { JSON.stringify(o, ['a', 'self']) } catch (e) { e.name }", 0.0); + assert_eq!(cyclic, "TypeError"); + let shared = f.text("var x = {a: 1, b: 2}; JSON.stringify({p: x, q: x}, ['p', 'q', 'a'])", 0.0); + assert_eq!(shared, r#"{"p":{"a":1},"q":{"a":1}}"#); +} diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index 1bfaf8c16..37b28c415 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[ "linear(time, 0, 1, 0, 100)", "ease(time, 0, 2, [0, 0], [100, 50])", "easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)", + "bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", + "bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])", "clamp(value * 2, 0, 100)", "length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]", "normalize([3, 4])[0] * degreesToRadians(180)", diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs index debe5e5ac..c96045ec2 100644 --- a/crates/expr/src/tests/property_group.rs +++ b/crates/expr/src/tests/property_group.rs @@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() { assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + // The layer it reaches is the same object as `thisLayer`. + assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0); let s = staggered(); assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index d86784f1e..32121ffcb 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -261,6 +261,9 @@ impl Mat3 { return Mat3([[x1 - x0, x3 - x0, x0], [y1 - y0, y3 - y0, y0], [0.0, 0.0, 1.0]]); } let den = dx1 * dy2 - dx2 * dy1; + if den == 0.0 || !den.is_finite() { + return Mat3::IDENTITY; + } let g = (sx * dy2 - dx2 * sy) / den; let h = (dx1 * sy - sx * dy1) / den; Mat3([[x1 - x0 + g * x1, x3 - x0 + h * x3, x0], [y1 - y0 + g * y1, y3 - y0 + h * y3, y0], [g, h, 1.0]]) @@ -449,6 +452,19 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 { mod tests { use super::*; + /// A quad with three collinear corners has no projective map: identity, not NaN. + #[test] + fn square_to_quad_degenerate_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 5.0), vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(20.0, 0.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite()), "non-finite: {m:?}"); + } + + #[test] + fn square_to_quad_regular_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(8.0, 6.0), vec2(0.0, 5.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite())); + } + fn close(a: Vec2, b: Vec2) -> bool { (a - b).length() < 1e-9 } diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 24c5a3b35..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option { /// Checking these before serialization bounds host packing as well as the GPU uploads. fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> { let mut sizes = [0; 7]; - sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?; + sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?; for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() { let size = count.checked_mul(stride)?.max(16); sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?; @@ -164,11 +164,11 @@ mod capability_tests { #[test] fn packed_arrays_preflight_exact_strides_and_empty_padding() { - assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16])); - assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16])); - assert!(packed_bytes([0; 6], 159, 16, 160).is_none()); - assert!(packed_bytes([0; 6], 160, 15, 160).is_none()); - assert!(packed_bytes([2; 6], 160, 192, 191).is_none()); + assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16])); + assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16])); + assert!(packed_bytes([0; 6], 175, 16, 176).is_none()); + assert!(packed_bytes([0; 6], 176, 15, 176).is_none()); + assert!(packed_bytes([2; 6], 176, 192, 191).is_none()); for index in 0..6 { let mut counts = [0; 6]; counts[index] = u64::MAX; @@ -203,7 +203,7 @@ mod capability_tests { }; for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] { let actual = pack(&scene).map(|bytes| bytes.len() as u64); - assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160)); + assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176)); } } } @@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec; 7] { globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]); globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]); globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]); - let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 }); + let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { + radiance: 0, + mips: 1, + irradiance: 0, + intensity: 0.0, + rotation: 0.0, + sun: [0.0; 3], + key: 0.0, + shadow: -1, + shadow_darkness: 0.0, + shadow_diffusion: 0.0, + }); globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]); 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]); + // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. + globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope, + // and its key fraction — how much of its light the shadow can remove (shade::env_shadow). + globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index 39dc0bdea..a3f05bf12 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index bcd929304..44e777b5a 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -74,6 +74,8 @@ struct Lease { pool: Weak>, /// The [`Enc`] that took it (see [`Pool::owned`]). owner: u64, + /// The pool's [`Pool::generation`] when it was taken. + generation: u64, } impl std::fmt::Debug for Lease { @@ -87,7 +89,7 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release_owned(self.texture.clone(), self.owner); + p.release_owned(self.texture.clone(), self.owner, self.generation); } } } @@ -118,6 +120,8 @@ pub(crate) struct Pool { /// working texture of another `Enc` before it is gone. owned: HashMap>>, next_owner: u64, + /// Raised by [`Pool::discard`]: a texture taken before it never comes back to the pool. + generation: u64, } /// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. @@ -276,7 +280,10 @@ impl Pool { } } - fn release_owned(&mut self, t: wgpu::Texture, owner: u64) { + fn release_owned(&mut self, t: wgpu::Texture, owner: u64, generation: u64) { + if generation != self.generation { + return; + } if let Some(m) = self.owned.get_mut(&owner) { m.entry((t.width(), t.height())).or_default().push(t); return; @@ -306,6 +313,18 @@ impl Pool { self.free.clear(); self.bytes = 0; } + + /// After a failed frame (#1319): drop every kept texture, the free ones and those waiting + /// for an open encoder or kept by one, and the textures still out never return. The + /// encoders' bookkeeping stays, so reuse remains ordered. + fn discard(&mut self) { + self.clear(); + self.released.clear(); + for kept in self.owned.values_mut() { + kept.clear(); + } + self.generation = self.generation.wrapping_add(1); + } } /// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`). @@ -1015,8 +1034,9 @@ impl GpuContext { } fn texture(&self, w: u32, h: u32) -> wgpu::Texture { + // Named with its size, so a GPU error about it says which texture failed (#1319). self.device.create_texture(&wgpu::TextureDescriptor { - label: None, + label: Some(&format!("working image {w}×{h}")), size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, @@ -1031,10 +1051,10 @@ impl GpuContext { /// one is zeroed. fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h))); + let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX)); let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); - let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, generation }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -1150,6 +1170,21 @@ impl GpuContext { s.clear(); } } + + /// After a failed frame: forget every texture and buffer kept for reuse, since any of them + /// may be one the device rejected (#1319). Later frames allocate afresh. + pub fn discard_kept(&self) { + self.clear_uploads(); + if let Ok(mut p) = self.pool.lock() { + p.discard(); + } + if let Ok(mut z) = self.zeros.lock() { + z.clear(); + } + if let Ok(mut c) = self.particle_states.lock() { + c.clear(); + } + } } /// Records GPU work for one render (a command encoder submitted at readback or the end). diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 1e97b7eab..5a2b80f1c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -22,6 +22,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -46,6 +60,7 @@ impl GBuf { /// Buf::pad: grow by `pad` transparent pixels on every side. pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + let pad = effectcraft_effects::pad_within_budget(self.img.width, self.img.height, pad); if pad == 0 { return Some(()); } @@ -166,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no /// readback. A region the device can't allocate leaves the buffer as it is. fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } } _ => b, }; @@ -291,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -311,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index ce7133822..510f91e8c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs index a2d2c24dd..944ca6c07 100644 --- a/crates/gpu/src/fx_sim.rs +++ b/crates/gpu/src/fx_sim.rs @@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf } let mut p = Params::default(); p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale); let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; Some(GBuf { img, ..b }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 94b95166a..c58ccd59e 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -97,6 +97,29 @@ pub use wgpu; use crate::context::Enc; +/// Why [`Gpu::within_memory`] discarded a frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrameError { + /// The GPU context is retired (device lost, a readback timed out, an uncaptured error). + Retired(String), + /// Out of video memory (or a driver reporting a failed allocation as an internal error), + /// with any validation errors it went on to cause. + Memory(String), + /// The device rejected a command with no allocation failure before it (an invalid texture + /// or parameters, #1319): a compositor bug, not memory pressure. + Validation(String), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::Retired(e) | FrameError::Memory(e) | FrameError::Validation(e) => f.write_str(e), + } + } +} + +impl std::error::Error for FrameError {} + /// The GPU compositor (cheap to clone; one device shared by all clones). #[derive(Clone)] pub struct Gpu { @@ -265,33 +288,42 @@ impl Gpu { } } - /// Run work in native thread-local out-of-memory and internal-error scopes. A failed - /// result is discarded and cached allocations are released; callers can retry with a fresh - /// CPU renderer. Some drivers report a failed allocation as an internal error rather than - /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. + /// Run work in native thread-local out-of-memory, internal-error and validation scopes. A + /// failed result is discarded and the textures and buffers kept for reuse are released; + /// callers can retry with a fresh CPU renderer. Some drivers report a failed allocation as an + /// internal error rather than out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), + /// so both count. A failed allocation leaves an invalid texture whose use is then a + /// validation error (`Texture::create_view`, #1319): caught here, it fails this frame only + /// instead of retiring the GPU compositor for the session as an uncaptured error. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. - pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { - self.ctx.check_health()?; + pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { + self.ctx.check_health().map_err(FrameError::Retired)?; #[cfg(not(target_arch = "wasm32"))] { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); + let validation = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); let out = f(); + let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); - match internal.or(oom) { - Some(e) => { - self.ctx.clear_uploads(); - Err(e.to_string()) - } - None => self.ctx.check_health().map(|()| out), + let memory = oom.is_some() || internal.is_some(); + // The allocation failure first: a validation error is often its consequence. + let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); + if errors.is_empty() { + return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired); } + // Its textures go back to the pool when dropped: drop them before emptying it. + drop(out); + self.ctx.discard_kept(); + let errors = errors.join("; then: "); + Err(if memory { FrameError::Memory(errors) } else { FrameError::Validation(errors) }) } #[cfg(target_arch = "wasm32")] { let out = f(); - self.ctx.check_health().map(|()| out) + self.ctx.check_health().map(|()| out).map_err(FrameError::Retired) } } diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 7934dee0c..9ac103557 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -23,16 +23,38 @@ struct Checkpoint { key: u64, steps: u64, n: u32, + /// The birth steps of its particles (see [`born`]). + born: u64, buf: wgpu::Buffer, used: u64, } +/// The particles born in the first `steps` steps of `births` (ascending birth steps) and a hash +/// of their birth steps: a checkpoint is only resumed from when the request's births before it +/// are the same (a keyframed Birth Rate isn't part of the system key, #1373). +fn born(births: &[u32], steps: u64) -> (u32, u64) { + let mut h = 0xcbf2_9ce4_8422_2325u64; + let mut n = 0u32; + for &b in births.iter().take_while(|&&b| u64::from(b) < steps) { + h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3); + n += 1; + } + (n, h) +} + #[derive(Default)] pub(crate) struct Checkpoints { list: Vec, clock: u64, } +impl Checkpoints { + /// Forget every kept state (after a failed frame, whose buffers may be invalid). + pub(crate) fn clear(&mut self) { + self.list.clear(); + } +} + fn pipes(g: &GpuContext) -> &Pipes { g.particles.get_or_init(|| { let d = &g.device; @@ -106,6 +128,10 @@ fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { out } +/// Simulations dispatched on the GPU (tests check the GPU path really ran). +#[cfg(test)] +pub(crate) static DISPATCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). /// /// With deferred readbacks (a browser worker) the state's readback is keyed by the request @@ -141,7 +167,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option { k.used = now; (k.steps, Some(k.buf.clone()), k.n) @@ -187,6 +213,8 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option CHECKPOINTS { let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; c.list.remove(i); diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index 0ca011350..b9d0a28a8 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -2,12 +2,15 @@ // Browser callbacks stay on their worker thread. The workspace's wgpu feature still // requires Send callbacks on non-atomic wasm, and ParticleSim requires Send + Sync. use std::collections::BTreeMap; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Condvar; use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; pub(crate) const TIMEOUT: Duration = Duration::from_secs(10); pub(crate) type Error = String; type Done = Box, Error>) + Send>; +const TIMED_OUT: &str = "GPU readback timed out; context retired"; struct Entry { deadline: web_time::Instant, @@ -20,9 +23,70 @@ struct State { retired: Option, #[cfg(target_arch = "wasm32")] timer_armed: bool, + /// The deadline worker is to exit: the context was dropped. + #[cfg(not(target_arch = "wasm32"))] + closed: bool, + /// How many times the deadline worker woke up (tests: it must not wake while idle). + #[cfg(all(test, not(target_arch = "wasm32")))] + worker_wakes: u64, +} +/// The state and, natively, the condition the deadline worker sleeps on. The worker owns an +/// `Arc` of this, not of the [`Readbacks`], so it can block on the condition without keeping +/// the context alive; dropping the context closes it. +#[derive(Default)] +struct Inner { + state: Mutex, + #[cfg(not(target_arch = "wasm32"))] + wake: Condvar, } #[derive(Default)] -pub(crate) struct Readbacks(Mutex); +pub(crate) struct Readbacks(Arc); + +/// Retire the context when a deadline has passed: the entries to fail, or `None` when none +/// is due (or it is retired already). Decides and drains under the lock the caller holds. +fn expire_locked(s: &mut State, now: web_time::Instant) -> Option> { + // Decide and drain atomically: a cancelled old deadline must not retire + // a newer request inserted between the deadline check and retirement. + if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { + return None; + } + s.retired = Some(TIMED_OUT.to_string()); + Some(std::mem::take(&mut s.pending).into_values().collect()) +} + +/// The deadline worker. It sleeps while nothing is pending, and otherwise exactly until the +/// earliest deadline or until what is pending changes, so an idle context costs no wakeups +/// (a 100 ms polling loop here was ten wakeups a second for the life of the app). When a +/// deadline has passed it retires the context and fails every pending readback. +#[cfg(not(target_arch = "wasm32"))] +fn run_deadlines(inner: &Inner) { + let mut s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if s.closed { + return; + } + let now = web_time::Instant::now(); + match s.pending.values().map(|e| e.deadline).min() { + None => s = inner.wake.wait(s).unwrap_or_else(std::sync::PoisonError::into_inner), + Some(deadline) if deadline > now => { + s = inner.wake.wait_timeout(s, deadline - now).unwrap_or_else(std::sync::PoisonError::into_inner).0; + } + Some(_) => { + let expired = expire_locked(&mut s, now); + drop(s); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); + } + s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + continue; + } + } + #[cfg(test)] + { + s.worker_wakes += 1; + } + } +} pub(crate) struct Ticket { owner: Weak, id: u64, @@ -50,23 +114,24 @@ impl Readbacks { let me = Arc::new(Self::default()); #[cfg(not(target_arch = "wasm32"))] { - let weak = Arc::downgrade(&me); + let inner = Arc::clone(&me.0); std::thread::Builder::new() .name("gpu-readback-deadlines".into()) - .spawn(move || { - loop { - std::thread::sleep(Duration::from_millis(100)); - let Some(me) = weak.upgrade() else { break }; - me.expire(web_time::Instant::now()); - } - }) + .spawn(move || run_deadlines(&inner)) .map_err(|e| format!("starting GPU readback deadline worker: {e}"))?; } Ok(me) } + /// Have the deadline worker look at what is pending again (natively; the browser's timer + /// re-arms itself). + fn poke(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.0.wake.notify_one(); + } + pub(crate) fn check(&self) -> Result<(), Error> { - let s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match &s.retired { Some(e) => Err(e.clone()), None => Ok(()), @@ -76,7 +141,7 @@ impl Readbacks { pub(crate) fn start(self: &Arc, done: impl FnOnce(Result, Error>) + wgpu::WasmNotSend + 'static) -> Result { let mut done: Option = Some(Box::new(done)); let result = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(e) = &s.retired { Err(e.clone()) } else if s.pending.len() >= 256 { @@ -96,8 +161,10 @@ impl Readbacks { { notify(done, Err(e.clone())); } - #[cfg(target_arch = "wasm32")] if result.is_ok() { + // The worker may be asleep with nothing pending: this is its new earliest deadline. + self.poke(); + #[cfg(target_arch = "wasm32")] self.arm_timer(); } result @@ -106,7 +173,7 @@ impl Readbacks { #[cfg(target_arch = "wasm32")] fn arm_timer(self: &Arc) { let delay = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.timer_armed || s.retired.is_some() { return; } @@ -123,13 +190,14 @@ impl Readbacks { pub(crate) fn retire(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.retired.is_some() { return; } s.retired = Some(error.clone()); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } @@ -137,34 +205,49 @@ impl Readbacks { pub(crate) fn cancel_pending(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } } + /// Retire the context when a deadline has passed (the browser's timer and tests; natively + /// the deadline worker does this itself). + #[cfg(any(test, target_arch = "wasm32"))] fn expire(&self, now: web_time::Instant) { - let error = "GPU readback timed out; context retired".to_string(); - let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // Decide and drain atomically: a cancelled old deadline must not retire - // a newer request inserted between the deadline check and retirement. - if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { - return; - } - s.retired = Some(error.clone()); - std::mem::take(&mut s.pending) + let expired = { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + expire_locked(&mut s, now) }; - for (_, entry) in pending { - notify(entry.done, Err(error.clone())); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); } } + + /// Move every pending deadline to `deadline` and wake the worker, as a test's stand-in for + /// waiting out the production timeout. + #[cfg(all(test, not(target_arch = "wasm32")))] + fn move_deadlines_for_test(&self, deadline: web_time::Instant) { + { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for entry in s.pending.values_mut() { + entry.deadline = deadline; + } + } + self.poke(); + } } impl Drop for Readbacks { fn drop(&mut self) { self.retire("GPU readback context dropped".into()); + #[cfg(not(target_arch = "wasm32"))] + { + self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).closed = true; + self.0.wake.notify_all(); + } } } @@ -194,12 +277,14 @@ impl Layout { impl Ticket { pub(crate) fn active(&self) -> bool { - self.owner.upgrade().is_some_and(|me| me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) + self.owner.upgrade().is_some_and(|me| me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) } pub(crate) fn complete(self, result: Result, Error>) { let Some(me) = self.owner.upgrade() else { return }; - let entry = me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); + let entry = me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); if let Some(entry) = entry { + // Its deadline is gone: the worker sleeps until the next one, or until something new. + me.poke(); notify(entry.done, result); } } @@ -215,7 +300,7 @@ fn schedule(me: Weak, delay: Duration) -> Result<(), Error> { .map_err(|_| "GPU deadline timer unavailable".to_string())?; let callback = Closure::once(move || { if let Some(me) = me.upgrade() { - me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; + me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; me.expire(web_time::Instant::now()); // If the earliest request completed, arm for the next live deadline. me.arm_timer(); @@ -377,7 +462,7 @@ mod tests { assert_eq!(deferred.in_flight(), 0); assert_eq!(wakes.0.load(Ordering::SeqCst), 1); assert!(matches!(settled.as_mut().poll(&mut Context::from_waker(&waker)), Poll::Ready(()))); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); rx.recv_timeout(Duration::from_secs(2)).unwrap(); @@ -390,17 +475,64 @@ mod tests { tx2.send(()).unwrap(); }) .unwrap(); - { - let mut state = me.0.lock().unwrap(); - for entry in state.pending.values_mut() { - entry.deadline = web_time::Instant::now(); - } - } + me.move_deadlines_for_test(web_time::Instant::now()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); assert!(me.check().is_err()); } + /// With nothing pending the deadline worker sleeps: no periodic wakeups (it used to poll + /// every 100 ms for the life of the app, ten wakeups a second while idle). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn an_idle_deadline_worker_does_not_wake_up() { + let me = Readbacks::new().unwrap(); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 1, "the worker woke {wakes} times while nothing was pending"); + // A readback that completes in time: the worker is told, and goes back to sleep. + let ticket = me.start(|r| assert!(r.is_ok())).unwrap(); + ticket.complete(Ok(vec![1])); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 3, "the worker woke {wakes} times for one completed readback"); + assert!(me.check().is_ok()); + } + + /// A deadline that passes wakes the worker exactly then, without the old polling loop. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_pending_deadline_wakes_the_worker_when_it_is_due() { + let me = Readbacks::new().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let _ticket = me + .start(move |r| { + assert!(r.is_err()); + tx.send(web_time::Instant::now()).unwrap(); + }) + .unwrap(); + let due = web_time::Instant::now() + Duration::from_millis(200); + me.move_deadlines_for_test(due); + let fired = rx.recv_timeout(Duration::from_secs(5)).expect("the deadline fires"); + assert!(fired >= due, "fired {:?} early", due - fired); + assert!(fired < due + Duration::from_secs(2), "fired {:?} late", fired - due); + assert!(me.check().is_err()); + } + + /// Dropping the last handle lets the worker exit: it holds the state, not the context. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn dropping_the_context_stops_the_deadline_worker() { + let me = Readbacks::new().unwrap(); + let inner = Arc::downgrade(&me.0); + drop(me); + let t0 = web_time::Instant::now(); + while inner.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still holding the state"); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn missing_callback_times_out_once_and_rejects_late_success() { let me = Arc::new(Readbacks::default()); diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..8ac143ad0 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -14,8 +14,10 @@ struct Globals { ambient: vec4, // radiance first texture, mip count, irradiance texture, intensity env: vec4, - // rotation, any lighting (0/1), -, - + // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness env2: vec4, + // the environment's dominant direction (its shadow's source) + env_sun: vec4, }; @group(0) @binding(0) var g: Globals; @@ -194,6 +196,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2 { + let h = tap_hash(x, y); + return vec2(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5); +} + +// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice +// cell (cx, cy), blended bilinearly by the receiver's position w in the cell. +fn tap_jitter(cx: i32, cy: i32, w: vec2, i: i32, j: i32) -> vec2 { + let x = cx + i * 7919; + let y = cy + j * 7919; + let c00 = tap_jitter_at(x, y); + let c10 = tap_jitter_at(x + 1, y); + let c01 = tap_jitter_at(x, y + 1); + let c11 = tap_jitter_at(x + 1, y + 1); + let a = c00 + (c10 - c00) * w.x; + let b = c01 + (c11 - c01) * w.x; + return a + (b - a) * w.y; +} + const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +265,8 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { // shadows off their casters), each texel compared against the receiver plane's // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid // jittered by an integer hash (bit-identical across backends) of the sub-texel - // cell and tap index, and bilinear compares of the four nearest texel centres. + // lattice and tap index, blended across the receiver's lattice cell, and bilinear + // compares of the four nearest texel centers. // extra.y is the wanted penumbra in output pixels; dividing by the receiver's // texel footprint keeps it in output pixels at any map resolution, and the reach // is at least two texels so hard edges ramp over texel steps instead of showing them. @@ -268,16 +290,17 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); let step = reach / f32(n - 1); let half = f32(n - 1) * 0.5; - let cx = i32(floor(u * 8.0)); - let cy = i32(floor(v * 8.0)); + let gu = u * 8.0; + let gv = v * 8.0; + let cx = i32(floor(gu)); + let cy = i32(floor(gv)); + let wc = vec2(gu - floor(gu), gv - floor(gv)); var lit = 0.0; for (var j = 0; j < n; j = j + 1) { for (var i = 0; i < n; i = i + 1) { - let h = tap_hash(cx + i * 7919, cy + j * 7919); - let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let su = u + ((f32(i) - half) + jx) * step; - let sv = v + ((f32(j) - half) + jy) * step; + let jit = tap_jitter(cx, cy, wc, i, j); + let su = u + ((f32(i) - half) + jit.x) * step; + let sv = v + ((f32(j) - half) + jit.y) * step; // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. let tu = su - 0.5; @@ -318,6 +341,18 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } +// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the +// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so +// the shadow stays partial the way After Effects renders an area-light shadow. +fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { + if (g.env2.z < 0.0) { + return 1.0; + } + let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); + let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); + return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f); +} + fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { var k = 1.0; if (falloff == 1u && kind != 0u) { @@ -404,6 +439,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 0.5) { + amount = max(amount, 1.0 - env_shadow(pos, n, n)); + } return amount; } @@ -543,7 +582,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { let f0 = mix(vec3(0.04 * spec_k), rgb, metallic); let ab = env_brdf(n_v, rough); let fs = f0 * ab.x + vec3(ab.y); - acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao; + var esh = 1.0; + if (receives_shadows) { + esh = env_shadow(i.pos, ng, n); + } + acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh; } let c = acc + emissive; out.color = vec4(c * alpha, alpha); diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl index 1c963385a..41996af36 100644 --- a/crates/gpu/src/shaders/classic3d.wgsl +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -199,6 +199,12 @@ fn attenuation(l: u32, p: vec3) -> f32 { return k; } +// compose::shadow_taps: samples per side of the Shadow Diffusion kernel. +fn shadow_taps(r_px: f32) -> u32 { + let n = clamp(u32(ceil(2.0 * r_px / 1.5)), 3u, 15u); + return n | 1u; +} + // compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { let pl = planes[c]; @@ -243,9 +249,14 @@ fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { if (r * pl.tex.z < 0.5) { px = texel(c, hit.x, hit.y); } else { - for (var j = -1; j <= 1; j++) { - for (var i = -1; i <= 1; i++) { - px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0; + // compose::shadow_taps: n × n box filter over the penumbra square. + let n = shadow_taps(r * pl.tex.z); + let step = 2.0 * r / f32(n); + let h = i32(n / 2u); + let w = 1.0 / f32(n * n); + for (var j = -h; j <= h; j++) { + for (var i = -h; i <= h; i++) { + px += texel(c, hit.x + f32(i) * step, hit.y + f32(j) * step) * w; } } } diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl index 5fbc76b4b..5e9e20a12 100644 --- a/crates/gpu/src/shaders/fx_distort.wgsl +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -238,7 +238,7 @@ fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 { if (o + 1u == n && frac > 0.0) { w = frac; } - sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; + sum += value_noise_linear_z(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; norm += amp * w; amp *= falloff; f *= 2.0; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index a63a91fed..2c6f5e50c 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -647,7 +647,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3) { // stylize3::hex_cell (cube rounding). let q = (0.5773502691896257 * u - v / 3.0) / r; let rr = (2.0 / 3.0 * v) / r; - let ccy = -q - rr; + // Evaluate the third cube coordinate symmetrically with q so the centerline + // tie is not broken by subtracting separately rounded coordinates. + let ccy = (-0.5773502691896257 * u - v / 3.0) / r; var rx = round_away(q); let ry = round_away(ccy); var rz = round_away(rr); diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index d936861d3..0c6023f32 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { return FxmShade(true, vec4(rgb * a, a)); } -// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer +// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within. fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { let qx = data[at + 1u]; let qy = data[at + 2u]; @@ -180,7 +181,9 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { } let k = select(-0.7, 1.6, P.u[1].y != 0u); let nz = sqrt(max(1.0 - d2, 0.0)); - let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z); + let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w); + let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 834c5f528..bb4a5e63a 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -525,12 +525,25 @@ fn fade(t: f32) -> f32 { } fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { + return value_noise_z(x, y, z, seed, true); +} + +// effects::generate::value_noise_linear_z: z (evolution) interpolates linearly, x and y keep the +// quintic fade (Turbulent Displace, #943). +fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 { + return value_noise_z(x, y, z, seed, false); +} + +fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 { let x0 = floor(x); let y0 = floor(y); let z0 = floor(z); let fx = fade(x - x0); let fy = fade(y - y0); - let fz = fade(z - z0); + var fz = z - z0; + if (ease_z) { + fz = fade(fz); + } let ix = i32(x0); let iy = i32(y0); let iz = i32(z0); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 4b3375411..c57c1ef84 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -498,7 +498,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { b.blend_mode = BlendMode::Difference; (a, b) }; - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let bg = s.footage(97, 61); s.push(bg); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); @@ -864,6 +864,71 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } +/// A texture the device rejected (deliberately too wide), made outside any frame under a scope +/// of its own: the view a frame then makes of it is the reported validation error. +fn rejected_texture(g: &Gpu) -> wgpu::Texture { + let scope = g.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1); + let t = g.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("deliberately too wide"), + size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + assert!(pollster::block_on(scope.pop()).is_some(), "the device rejected the texture"); + t +} + +/// A frame that makes a view of an invalid texture ("Texture with '' label is invalid", #1319) +/// fails alone: the validation error doesn't reach the device's handler (which retires the +/// compositor for the session, and panics in tests), and the next frame composites on the GPU, +/// read back, like the CPU. +#[test] +fn a_view_of_an_invalid_texture_fails_the_frame_not_the_compositor() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let err = g.within_memory(|| rejected.create_view(&Default::default())).expect_err("the frame fails"); + assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}"); + assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); + let s = blend_scene(BitDepth::Bpc32, BlendMode::Normal); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.within_memory(|| g.render(&r, s.cid, Tick::ZERO)).expect("the next frame isn't rejected").expect("the GPU renders it"); + let mut d = diff(&cpu, &img, tolerance(BitDepth::Bpc32)); + d.quantized = false; + check("after a rejected frame", Some(d), 0.0); +} + +/// No texture of a failed frame is reused: not one released while another encoder is still +/// open (it waits for that encoder), nor one taken before the failure and handed back after. +#[test] +fn a_failed_frame_returns_no_texture_to_the_pool() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame"); + let mut other = crate::context::Enc::new(&g.ctx); + other.encoder(); + g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails"); + drop(earlier); + drop(other); + let (hits, _) = g.ctx.pool_stats(); + let (a, b) = g + .within_memory(|| { + let mut e = crate::context::Enc::new(&g.ctx); + (e.image(64, 32), e.image(48, 16)) + }) + .expect("the next frame"); + assert_eq!(((a.width, a.height), (b.width, b.height)), ((64, 32), (48, 16))); + assert_eq!(g.ctx.pool_stats().0, hits, "no texture of the failed frame was reused"); +} + /// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an /// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of /// panicking in `create_buffer_init`'s mapped range on a frame thread (#591). diff --git a/crates/gpu/src/tests/adjust.rs b/crates/gpu/src/tests/adjust.rs index 5635e08e1..39438d43e 100644 --- a/crates/gpu/src/tests/adjust.rs +++ b/crates/gpu/src/tests/adjust.rs @@ -83,7 +83,7 @@ fn track_mattes_limit_gpu_adjustments_like_the_cpu() { set(&mut matte, "transform/rotation", Value::Scalar(10.0)); let mid = s.push(matte); let c = s.p.comp_mut(s.cid).unwrap(); - let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap(); + let adj = c.layers.find_mut(|l| l.switches.adjustment).unwrap(); adj.track_matte = Some(TrackMatte { layer: mid, kind }); check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); } diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..00b1fee14 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -5,7 +5,7 @@ //! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most //! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter. -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Gradient, Value}; use effectcraft_project::build; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid}; use effectcraft_render::three_d::adv::{self, Scene}; @@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) { } /// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light, -/// an environment light, a semi-transparent textured card. +/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent +/// textured card. pub(crate) fn scene_project() -> (Project, ItemId) { let mut p = Project::default(); let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); @@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) { set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0])); set(l, "transform/rotationY", Value::Scalar(30.0)); }); - let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 })); - add(&mut p, cid, LayerSource::Solid { item: env }, |l| { - l.environment = true; + // The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky, + // shadows the scene from above). + add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + false, + [0.0, -80.0], + [0.0, 80.0], + Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/intensity", Value::Scalar(50.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0)); }); - add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0))); add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| { set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0])); set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0])); @@ -96,7 +114,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) { pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { let r = Renderer::new(p, &NoFootage, RenderOpts::default()); - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) } @@ -135,6 +162,8 @@ fn gpu_matches_cpu_lit_scene() { let (p, cid) = scene_project(); let s = scene(&p, cid); assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32); + // The Environment light casts its shadow (the sky gradient's dominant direction). + assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built"); compare(&s, &g); } @@ -181,7 +210,7 @@ fn gpu_matches_cpu_wide_penumbrae() { set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); let s = scene(&p, cid); let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); - for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + for l in p.comp_mut(cid).unwrap().layers.iter_mut().take(2) { set(l, "lightOptions/castsShadows", Value::Bool(false)); } let unshadowed = adv::raster::render(&scene(&p, cid)); @@ -251,7 +280,7 @@ pub(crate) fn dof_mb_project() -> (Project, ItemId) { c.enable_motion_blur = true; c.motion_blur_samples = 6; // The sphere moves 40 px per frame. - let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); + let sphere = c.layers.find_mut(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); sphere.switches.motion_blur = true; sphere.props.prop_mut("transform/position").unwrap().keys = vec![ effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])), @@ -277,7 +306,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) { } pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) { - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) } @@ -411,7 +449,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() { eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0); // A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU). - p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = + p.comp_mut(cid).unwrap().layers.find_mut(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = effectcraft_color::BlendMode::Screen; let (ctx, run) = run_of(&p, cid); assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none()); @@ -532,12 +570,12 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { }); } let c = p.comp_mut(cid).unwrap(); - let env = c.layers.iter_mut().find(|l| l.environment).unwrap(); + let env = c.layers.find_mut(|l| l.environment).unwrap(); env.props.sub_mut("effects").unwrap().children.push(ramp.into()); let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect(); let top = c.layers[0].id; fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer { - c.layers.iter_mut().find(|l| l.id == id).unwrap() + c.layers.find_mut(|l| l.id == id).unwrap() } match case { 0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen, @@ -555,7 +593,7 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { } _ => { // An environment background behind the run, and the 2D track matte. - c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true; + c.layers.find_mut(|l| l.environment).unwrap().environment_background = true; by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha }); } } diff --git a/crates/gpu/src/tests/fx_distort.rs b/crates/gpu/src/tests/fx_distort.rs index 19cdcd605..1b8621408 100644 --- a/crates/gpu/src/tests/fx_distort.rs +++ b/crates/gpu/src/tests/fx_distort.rs @@ -55,6 +55,11 @@ fn turbulent_displace_and_cc_lens() { ], ); effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]); + // #943: Evolution moves the pattern at a constant rate on both paths, including steps of a + // few degrees and angles past a whole revolution, where an eased evolution axis diverges. + for evolution in [10.0, 37.0, 400.0, 725.5] { + effect_case("ec.distort.turbulentdisplace", &[("evolution", n(evolution)), ("size", n(12.0)), ("amount", n(30.0))]); + } effect_case("ec.distort.cclens", &[("size", n(70.0))]); effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]); effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 38a30aca5..7d13004ee 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -208,7 +208,7 @@ fn echo_and_posterize_time() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_sim.rs b/crates/gpu/src/tests/fx_sim.rs index dc2510bb3..4f54e07b6 100644 --- a/crates/gpu/src/tests/fx_sim.rs +++ b/crates/gpu/src/tests/fx_sim.rs @@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() { case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); } +/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the +/// refraction samples to the layer rectangle, not to the buffer (#2024). +#[test] +fn bubbles_on_padded_buffers() { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap(); + let size = [70.0, 44.0]; + for reflection in [0, 1] { + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] { + params.values.insert(k.to_string(), v); + } + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + buf.pad(6); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst); + } + } +} + #[test] fn hair_wave_world_foam() { case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); diff --git a/crates/gpu/src/tests/fx_time.rs b/crates/gpu/src/tests/fx_time.rs index 0c94bb413..c71355736 100644 --- a/crates/gpu/src/tests/fx_time.rs +++ b/crates/gpu/src/tests/fx_time.rs @@ -40,7 +40,7 @@ fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 08212a12a..40bc9483f 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -164,7 +164,7 @@ fn color_stabilizer() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; let op = a.props.prop_mut("transform/opacity").unwrap(); op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); let vals = [ ("stabilize", e(mode)), diff --git a/crates/gpu/src/tests/particles.rs b/crates/gpu/src/tests/particles.rs index d2a229b0f..e11dd6585 100644 --- a/crates/gpu/src/tests/particles.rs +++ b/crates/gpu/src/tests/particles.rs @@ -91,6 +91,79 @@ fn particle_world_matches_the_cpu() { } } +/// A host whose Birth Rate is `rate(layer time)` (#1373), with the GPU backend or without (the +/// CPU oracle). +struct Keyed { + gpu: Option<&'static Gpu>, + rate: fn(f64) -> f64, +} + +impl EffectHost for Keyed { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + self.gpu.map(|g| g as &dyn ParticleSim) + } + fn param_at(&self, _effect_index: usize, id: &str, t: f64) -> Option { + (id == "birthRate").then(|| n((self.rate)(t))) + } +} + +/// Effect `id`'s live particles at layer time `t` through `host`. +fn keyed_state(id: &str, p: &Params, t: f64, host: &Keyed) -> Vec { + let env = EffectEnv { host: Some(host), ..Default::default() }; + let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + effectcraft_effects::particle_state(id, &ctx).unwrap() +} + +/// 3 a second from 0 to 0.5 s. +fn early(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// 3 a second from 0.25 to 0.75 s: as many births by 0.9 s as [`early`], at other steps. +fn late(t: f64) -> f64 { + if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 } +} + +/// Effect `id` (parameters `p`) under the [`early`] then the [`late`] Birth Rate history: the +/// GPU's particles match the CPU's, and each really came from the GPU. +fn keyed_agree(id: &str, p: &Params, g: &'static Gpu) { + for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { + let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); + for t in times { + let cpu = keyed_state(id, p, t, &cpu_host); + let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed); + let gpu = keyed_state(id, p, t, &gpu_host); + assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "{id} t {t}: simulated on the GPU"); + agree(&format!("{id} keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); + } + } +} + +/// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU +/// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one +/// history is not resumed for another with the same system key, even when both have born as +/// many particles (180 by 0.9 s here) at other steps. +#[test] +fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]); + keyed_agree("ec.sim.ccparticleworld", &p, g); +} + +/// The same for CC Particle Systems II (90 births by 0.9 s in both histories). +#[test] +fn particle_systems_ii_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let p = params("ec.sim.ccparticlesystems2", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("randomSeed", n(22.0))]); + keyed_agree("ec.sim.ccparticlesystems2", &p, g); +} + #[test] fn particle_systems_ii_matches_the_cpu() { let Some(g) = gpu() else { return }; diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index 32f11a341..acdbea7b1 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -133,6 +133,22 @@ fn lights_specular_transmission_and_shadows() { } } +#[test] +fn wide_shadow_diffusion() { + // #2444: a wide penumbra takes more than the 3 × 3 kernel's samples; both sides pick the + // same count. + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.switches.three_d { + set(l, "materialOptions/castsShadows", Value::Enum(1)); + } + } + light(&mut s, LightKind::Point, [150.0, 20.0, -80.0], &[("castsShadows", Value::Bool(true)), ("shadowDiffusion", n(60.0))]); + check(&format!("wide shadow diffusion {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + #[test] fn depth_of_field_and_motion_blur() { for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { @@ -161,7 +177,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { let mid = s.push(m); let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); - if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + if let Some(far) = layers.find_mut(|l| l.switches.three_d && l.name == "Pattern") { far.preserve_transparency = true; } // Move the matte below the bottom so the 3D run stays one run. @@ -187,7 +203,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { a.switches.three_d = true; set(&mut a, "transform/rotationX", Value::Scalar(40.0)); let b = s.solid([0.2, 0.8, 0.4], 30, 20); - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); pre.switches.collapse = true; pre.switches.three_d = true; @@ -283,7 +299,7 @@ fn two_d_layer_with_a_three_d_track_matte() { let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; let fi = layers.iter().position(|l| l.id == fid).unwrap(); layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); - if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + if let Some(ml) = layers.find_mut(|l| l.id == mid) { ml.switches.video = false; } check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 78a6857fb..d4d7a62a1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut i = j; } else { match r.collapsed(ctx, visible[i]) { - Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?, + Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?, None => draw_layer(e, r, ctx, visible[i], canvas)?, } i += 1; @@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend Some(cv) } -fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { +fn draw_collapsed<'a>( + e: &mut Enc, + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + collapsed: &effectcraft_render::Collapsed, + canvas: &mut GpuImage, +) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { return Some(()); } let matte = r.track_matte(ctx, layer).is_some(); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) { draw_comp(e, &sub, &nctx, canvas)?; } return Some(()); } - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) }; let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) diff --git a/crates/hevcenc/tests/roundtrip.rs b/crates/hevcenc/tests/roundtrip.rs index 777bc455b..7591517e9 100644 --- a/crates/hevcenc/tests/roundtrip.rs +++ b/crates/hevcenc/tests/roundtrip.rs @@ -132,12 +132,36 @@ fn ffmpeg_available() -> bool { Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false) } +/// `true` when this ffmpeg/ffprobe build actually ships an HEVC decoder. Distro "free" builds +/// (e.g. Fedora's ffmpeg-free) ship without one — the tools run but cannot decode our stream, so +/// the external oracle means nothing and the test skips rather than failing on a gap in the tool. +fn hevc_decoder_available() -> bool { + Command::new("ffmpeg") + .args(["-hide_banner", "-decoders"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout).lines().any(|l| { + // Decoder rows look like ` V....D hevc ...`; match the codec name, not the text. + // Every row of `-decoders` is a decoder, so the flags column says nothing about + // that — its trailing `D` is "supports direct rendering method 1", which a + // build's hevc decoder may well lack (requiring it would skip a working oracle). + let mut it = l.split_whitespace(); + it.next().is_some() && it.next() == Some("hevc") + }) + }) + .unwrap_or(false) +} + /// Decode the Annex-B stream with ffmpeg and require bit-exact equality and an empty stderr. fn check_ffmpeg(run: &Run, w: usize, h: usize, bd: u32, tag: &str) { if !ffmpeg_available() { eprintln!("ffmpeg not found; skipping the ffmpeg oracle"); return; } + if !hevc_decoder_available() { + eprintln!("ffmpeg has no HEVC decoder here; skipping the ffmpeg oracle"); + return; + } let dir = std::env::temp_dir(); let uniq = format!("{}-{}-{}", tag, std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); let inp = dir.join(format!("hevcenc-{uniq}.hevc")); @@ -197,6 +221,10 @@ fn ffprobe_stream_info() { if !ffmpeg_available() { return; } + if !hevc_decoder_available() { + eprintln!("ffmpeg has no HEVC decoder here; ffprobe cannot fully describe our stream, skipping"); + return; + } let (w, h) = (130, 72); let pics: Vec = (0..2).map(|i| synth(w, h, 10, i, 0)).collect(); let mut c = cfg(w as u32, h as u32, 30, 30, Profile::Main10); diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index a73f15937..3114c2ddb 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() { let v3 = |s: &effectcraft_engine::Session, path: &str| { let comp = s.project.comp(left).unwrap(); let layer = &comp.layers[0]; - let ctx = - effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); diff --git a/crates/host/tests/it/background_presets.rs b/crates/host/tests/it/background_presets.rs index e3e44b009..c03ec0066 100644 --- a/crates/host/tests/it/background_presets.rs +++ b/crates/host/tests/it/background_presets.rs @@ -58,3 +58,34 @@ fn every_background_preset_renders_moves_and_undoes() { assert_eq!(fx, 0, "{id}: undo removes the effects"); } } + +/// #1510: each Behaviors preset keeps its layer moving (position, scale, rotation or opacity +/// changes over time) and renders finite, deterministic frames. +#[test] +fn every_behavior_preset_renders_and_moves() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "BH", "width": 160, "height": 90, "frameRate": 30, "duration": 8})).unwrap(); + // A small white square in the middle so position, scale and rotation changes are visible. + let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + for p in &list { + let id = p["id"].as_str().unwrap(); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}"); + let mut frames = Vec::new(); + // Times chosen off the periods of every preset so no two samples coincide. + for t in [0.03, 0.13, 0.41, 0.97, 1.73] { + let (px, _, _, nan) = frame(&s, t); + assert!(!nan, "{id} @ {t}: NaN or infinite pixel"); + let (again, ..) = frame(&s, t); + assert!(px == again, "{id} @ {t}: render is not deterministic"); + frames.push(px); + } + let moved = (1..frames.len()).any(|i| diff(&frames[0], &frames[i]) > 20.0); + assert!(moved, "{id}: the layer does not move ({:?})", (1..frames.len()).map(|i| diff(&frames[0], &frames[i])).collect::>()); + s.execute("edit.undo", json!({})).unwrap(); + } +} diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index faf2b09e3..876c543ec 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -146,27 +146,58 @@ fn a_file_still_being_written_is_tried_again() { assert!(near(pixel(&s, cid, 32, 24), BLUE)); } +/// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app +/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`]; +/// while nothing changes it hands over nothing (so the interface is never woken for it). #[test] -fn background_scan_reloads_changed_footage() { - let path = tmp("scan.psd"); +fn the_watcher_notices_a_footage_file_changed_on_disk() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + let path = tmp("watched.psd"); std::fs::write(&path, flat(RED, false)).unwrap(); let (mut s, _, cid) = flat_in_comp(&path); - let scan = |s: &mut Session| { - footage_reload::start_scan(s); - let t0 = std::time::Instant::now(); + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let fast = Duration::from_millis(20); + let mut w = footage_reload::Watcher::start_with( + s.services.clone(), + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + fast, + Duration::from_secs(60), + ) + .unwrap(); + w.set_focused(true); + w.set_watched(footage_reload::watched(&s)); + // Report `n`: the worker stores the stamps and then wakes the interface, so wait for the + // wake too, not only for the stamps. + let next = |w: &footage_reload::Watcher, n: usize| { + let t0 = Instant::now(); loop { - if let Some(r) = footage_reload::poll_scan(s) { - return r.unwrap(); + if wakes.load(Ordering::SeqCst) >= n + && let Some(stamps) = w.take() + { + return stamps; } - assert!(t0.elapsed().as_secs() < 10, "scan never finished"); - std::thread::yield_now(); + assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported"); + std::thread::sleep(Duration::from_millis(5)); } }; - assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + // First look: remembered, nothing to reload. + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 1)).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + std::thread::sleep(fast * 15); + assert!(w.take().is_none(), "an unchanged file is never reported"); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface"); + // Saved in another app: reported once, reloaded, shown. save(&path, &flat(GREEN, true)); - assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 2)).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); - assert!(s.footage_scan.is_none()); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + std::thread::sleep(fast * 10); + assert!(w.take().is_none()); } #[test] diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 73fc04841..826363edb 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -6,9 +6,11 @@ mod determinism; mod exr_layers; mod footage_reload; mod gif_animates; +mod pick_whip; mod plugins; mod relink; mod tga_import; mod timeline_interop; mod vector_import; +mod wav_markers; mod webm_metadata; diff --git a/crates/host/tests/it/pick_whip.rs b/crates/host/tests/it/pick_whip.rs new file mode 100644 index 000000000..340cda053 --- /dev/null +++ b/crates/host/tests/it/pick_whip.rs @@ -0,0 +1,38 @@ +//! The pick whip's references read the property they were made from (#1072): an Alt pick whip's +//! (`byIndex`) select an effect's properties by index, `effect("Fractal Noise")(i)(j)`, and must +//! read the same value as the reference by name. +//! +//! (With Compact English off, a reference to another layer, `thisComp.layer("Other")("effects")…`, +//! doesn't evaluate yet, by name or by index: "not a callable function".) + +use serde_json::{Value, json}; + +#[test] +fn index_references_read_the_same_values_as_names() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Reader", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + s.execute("prop.set", json!({"layer": o, "path": "effects/#1/transform/scale", "value": 77})).unwrap(); + s.execute("prop.set", json!({"layer": o, "path": "effects/#2/point", "value": [12, 34]})).unwrap(); + let evaluated = |s: &mut effectcraft_engine::Session, path: &str| -> Value { + let v = s.execute("prop.get", json!({"layer": l, "path": path})).unwrap(); + assert!(v.get("expressionError").is_none(), "{v}"); + v["evaluated"].clone() + }; + for (path, target, dimension, want) in + [("transform/rotation", "effects/#1/transform/scale", None, json!(77.0)), ("transform/opacity", "effects/#2/point", Some(1), json!(34.0))] + { + let mut t = json!({"layer": o, "path": target}); + if let Some(d) = dimension { + t["dimension"] = json!(d); + } + for by_index in [false, true] { + let e = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": t, "byIndex": by_index})).unwrap(); + assert_eq!(evaluated(&mut s, path), want, "by index {by_index}: {e}"); + } + } +} diff --git a/crates/host/tests/it/timeline_interop.rs b/crates/host/tests/it/timeline_interop.rs index 5123dc721..9688a409f 100644 --- a/crates/host/tests/it/timeline_interop.rs +++ b/crates/host/tests/it/timeline_interop.rs @@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId { color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index 36d83dd1a..badd60b66 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -509,7 +509,7 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]); assert_eq!(c.layers[0].id.0, sid); assert_eq!(r["layers"].as_array().unwrap().len(), 2); - for l in &c.layers[1..3] { + for l in c.layers.iter().skip(1).take(2) { assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name); assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. })); } diff --git a/crates/host/tests/it/wav_markers.rs b/crates/host/tests/it/wav_markers.rs new file mode 100644 index 000000000..98d2685dc --- /dev/null +++ b/crates/host/tests/it/wav_markers.rs @@ -0,0 +1,126 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. + +use effectcraft_engine::Session; +use effectcraft_project::LayerSource; +use serde_json::json; + +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + +/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a +/// `LIST` `adtl`). +fn marker_wav(points: &[(u32, u32, &str)]) -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for &(id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the +/// import is available to Update Markers From Source (and a layer already made keeps its own). +#[test] +fn reloading_a_wav_picks_up_cue_points_added_to_the_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let (layer, item) = s + .active_comp() + .unwrap() + .layers + .iter() + .find_map(|l| match l.source { + LayerSource::Footage { item } => Some((l.id.0, item.0)), + _ => None, + }) + .unwrap(); + assert_eq!(names(&mut s, layer), ["Intro"]); + // The file gains a cue point; the layer does not change until asked. + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap(); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap(); + assert_eq!(r["reloaded"], 1, "{r}"); + assert_eq!(names(&mut s, layer), ["Intro"]); + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Added later"]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..594620f65 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat return Some(format!("blend mode {}", l.blend_mode.label())); } // Premiere's Basic Motion in the interchange formats has one (uniform) scale. - if let Some(s) = l.transform().and_then(|t| t.get("scale")) - && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() > 1e-9 - }) - { + if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) { return Some("non-uniform scale".into()); } if l.preserve_transparency { @@ -581,10 +576,7 @@ impl Exporter<'_> { } } if let Some(p) = tr.get("scale") { - let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() < 1e-9 - }); + let uniform = !non_uniform_scale(p); m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); if !uniform { m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); @@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool { (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 } +/// Whether a scale property is non-uniform at some point: active key values, or the static +/// value when every key is muted (muted keys are not exported, #1894). +fn non_uniform_scale(p: &Property) -> bool { + let non_uniform = |v: &Value| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }; + if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) } +} + /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) @@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { /// A property → a FilmCraft parameter (layer-time keys → clip-time keys). fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { let mut out = fp::Param::new(conv(&p.value)); - for k in &p.keys { + // XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so + // the exported animation matches what the renderer shows (#1894). + for k in p.keys.iter().filter(|k| !k.muted) { let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); fk.interp = match k.out_interp { Interp::Linear => fp::Interpolation::Linear, @@ -684,8 +688,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option MAX_DEPTH { return Err(Error::TooDeep(MAX_DEPTH)); } + // FilmCraft's parser expands internal entities without a node limit (#2129); Final Cut Pro + // and FCPXML exports never declare any. + if matches!(format, TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml) && bytes.windows(8).any(|w| w == b" { MediaKind::AudioOnly => FootageKind::Audio, MediaKind::Still => FootageKind::Still, MediaKind::ImageSequence => FootageKind::Sequence, - _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ if v.is_none() && info.has_audio() => FootageKind::Audio, _ => FootageKind::Video, }; let _ = name; @@ -284,7 +289,7 @@ impl Importer<'_> { frame_rate: rate_in(info.frame_rate()), duration: tick_in(info.duration), has_video: kind != FootageKind::Audio, - has_audio: info.audio.is_some() || kind == FootageKind::Audio, + has_audio: info.has_audio() || kind == FootageKind::Audio, alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, codec: v.map(|v| v.codec.clone()).unwrap_or_default(), missing: true, diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs index 34d9aeab3..5b03403df 100644 --- a/crates/interchange/src/lib.rs +++ b/crates/interchange/src/lib.rs @@ -160,6 +160,8 @@ pub enum Error { NoComp(u64), #[error("the timeline document nests more than {0} levels deep")] TooDeep(usize), + #[error("the timeline document declares XML entities, which timeline files don't use and which can expand without bound")] + XmlEntities, } pub type Result = std::result::Result; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..1257ba3fd 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }) + .into_iter() + .collect(), container: String::new(), start_timecode: None, file_size: None, @@ -411,7 +415,7 @@ fn ae_project() -> (Project, ItemId) { let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None); ls.out_point = t(3.0); ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]); - c.layers = vec![lt, lp, la, ls]; + c.layers = vec![lt, lp, la, ls].into(); let cid = comp_item(&mut p, "Main", c); (p, cid) } @@ -480,6 +484,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property { l.transform().unwrap().get("position").unwrap() } +/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe +/// (the export would otherwise animate through it). +#[test] +fn export_skips_muted_keyframes() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let pos = l.transform_mut().unwrap().get_mut("position").unwrap(); + pos.keys = vec![ + Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])), + Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])), + Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])), + ]; + pos.keys[1].muted = true; + } + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap(); + let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap(); + assert_eq!(pos.keys.len(), 2, "the muted key was exported"); + assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0); + assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0); +} + +/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the +/// active keys matter, and the static value when every key is muted. +#[test] +fn muted_non_uniform_scale_does_not_force_prerendering() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let scale = l.transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))]; + scale.keys[1].muted = true; + } + let lid = l.id; + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None); + // With every key muted the static value drives evaluation, so a non-uniform static scale + // still takes the flattened path. + let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys[0].muted = true; + scale.value = Value::Vec3([50.0, 200.0, 100.0]); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale")); +} + #[test] fn export_without_prerender_leaves_text_out() { let (p, cid) = ae_project(); @@ -520,6 +577,21 @@ fn export_survives_a_precomp_layer_pointing_at_a_non_comp() { assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_)))); } +#[test] +fn xml_timelines_declaring_entities_are_refused() { + // #2129: the timeline parser expands internal entities without a node limit. + let dtd = "]>"; + let docs = [ + (TimelineFormat::Fcp7Xml, format!("{dtd}&b;")), + (TimelineFormat::Fcpxml, format!("{dtd}&b;")), + ]; + for (format, doc) in docs { + let mut p = Project::default(); + let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None); + assert!(matches!(r, Err(Error::XmlEntities)), "{format:?}"); + } +} + #[test] fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { // A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack. diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f2d94f177..abf929fe9 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,7 +15,8 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; +use std::borrow::Borrow; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. @@ -89,12 +90,19 @@ pub struct Keyframe { /// Keyframe color label: index into the label list (0 = None). #[serde(default, skip_serializing_if = "is_zero")] pub label: u8, + /// Muted keys stay in the list (and the UI) but evaluation skips them. + #[serde(default, skip_serializing_if = "is_false")] + pub muted: bool, } fn is_zero(v: &u8) -> bool { *v == 0 } +fn is_false(v: &bool) -> bool { + !*v +} + fn yes() -> bool { true } @@ -116,6 +124,7 @@ impl Keyframe { spatial_continuous: false, roving: false, label: 0, + muted: false, } } pub fn hold(mut self) -> Keyframe { @@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] { /// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and /// key times play no part. An end key uses its one neighbour, so the path leaves the first key /// and enters the last one toward the neighbouring key instead of with no tangent. -fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { +fn auto_spatial>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i.saturating_sub(1)].value); - let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let a = v3(&keys[i.saturating_sub(1)].borrow().value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; // A side facing a key at the same position gets no tangent, so the segment between two // identical keys has zero length and the layer holds still (#1675). - let p = v3(&keys[i].value); + let p = v3(&keys[i].borrow().value); let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; (tin, tout) @@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + tangents_of(keys, i) +} + +/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]). +fn tangents_of>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { + let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) }; if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } @@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). -fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { - let k = &keys[i]; +fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease { + let k = keys[i].borrow(); let list = if out { &k.out_ease } else { &k.in_ease }; if k.auto_bezier && i > 0 && i + 1 < keys.len() { - let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time); + let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time); let slope = if spatial { - let a = v3(&keys[i - 1].value); - let c = v3(&keys[i + 1].value); + let a = v3(&keys[i - 1].borrow().value); + let c = v3(&keys[i + 1].borrow().value); ((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12) } else { - let a = keys[i - 1].value.components(); - let c = keys[i + 1].value.components(); + let a = keys[i - 1].borrow().value.components(); + let c = keys[i + 1].borrow().value.components(); (c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12) }; return Ease { speed: slope, influence: 1.0 / 6.0 }; @@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) { } /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. +/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None` +/// means every key is muted (the property's static value applies). pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { - let first = keys.first()?; + if !keys.iter().any(|k| k.muted) { + return evaluate_active(keys, t, spatial); + } + // Muting is rare: skip the muted keys by reference rather than copying every key and value. + let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect(); + evaluate_active(&active, t, spatial) +} + +fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> Option { + let first = keys.first()?.borrow(); if keys.len() == 1 || t <= first.time { return Some(first.value.clone()); } - let last = keys.last()?; + let last = keys.last()?.borrow(); if t >= last.time { return Some(last.value.clone()); } - let i = keys.partition_point(|k| k.time <= t) - 1; - Some(segment_value(keys, i, t, spatial)) + let i = keys.partition_point(|k| k.borrow().time <= t) - 1; + Some(segment_value_of(keys, i, t, spatial)) } /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { - let a = &keys[i]; - let b = &keys[i + 1]; + segment_value_of(keys, i, t, spatial) +} + +/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]). +fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value { + let a = keys[i].borrow(); + let b = keys[i + 1].borrow(); if a.out_interp == Interp::Hold || !a.value.interpolates() { return a.value.clone(); } @@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val if is_spatial { let p0 = v3(&a.value); let p3 = v3(&b.value); - let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (_, out_t) = tangents_of(keys, i); + let (in_t, _) = tangents_of(keys, i + 1); let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]); let len = seg.length(); let f = if lin_out && lin_in || len <= 1e-12 { @@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_continuous = old.spatial_continuous; k.roving = old.roving; k.label = old.label; + k.muted = old.muted; keys[i] = k; i } diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 0a31cfc19..312703239 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -395,3 +395,59 @@ proptest::proptest! { proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); } } + +#[test] +fn muted_keys_are_skipped_by_evaluation() { + let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); + assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); + keys[1].muted = true; + // The 0 → 300 segment now spans 0 → 2 s. + assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9); + assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9); + for k in &mut keys { + k.muted = true; + } + assert!(evaluate(&keys, s(0.5), false).is_none()); +} + +#[test] +fn muted_key_automation_uses_the_active_neighbours() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])), + ]; + for k in &mut keys { + k.auto_bezier = true; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + let all = keys.clone(); + let mut active = keys.clone(); + active.remove(1); + keys[1].muted = true; + for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] { + assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}"); + } + // The muted auto-Bezier key no longer bulges the motion path. + assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true)); +} + +#[test] +fn mute_round_trips_and_survives_value_replacement() { + let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0)); + let json = serde_json::to_value(&k).unwrap(); + assert!(json.get("muted").is_none(), "false is the default and is skipped"); + k.muted = true; + let json = serde_json::to_value(&k).unwrap(); + assert_eq!(json["muted"], serde_json::json!(true)); + let back: Keyframe = serde_json::from_value(json).unwrap(); + assert!(back.muted); + let mut keys = vec![k]; + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0))); + assert!(keys[0].muted); + assert_eq!(keys[0].value, Value::Scalar(2.0)); +} diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 6c1e5b843..96a8b6be3 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -179,6 +179,31 @@ pub enum Composer { fn single_line() -> Composer { Composer::SingleLine } + +/// How the corners of a text stroke are drawn (Character panel Line Join). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + /// Sharp corners (AE's default for new text). + #[default] + Miter, + Round, + Bevel, +} + +impl LineJoin { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "miter" => LineJoin::Miter, + "round" => LineJoin::Round, + "bevel" => LineJoin::Bevel, + _ => return None, + }) + } +} + +fn round_join() -> LineJoin { + LineJoin::Round +} fn yes() -> bool { true } @@ -402,6 +427,9 @@ pub struct TextDoc { pub apply_fill: bool, pub apply_stroke: bool, pub stroke_over_fill: bool, + /// Documents saved before line joins existed drew round joins. + #[serde(default = "round_join")] + pub line_join: LineJoin, pub tracking: f64, /// None = auto (120% of size). pub leading: Option, @@ -463,6 +491,7 @@ impl Default for TextDoc { apply_fill: true, apply_stroke: false, stroke_over_fill: true, + line_join: LineJoin::Miter, tracking: 0.0, leading: None, justify: Justify::Left, @@ -909,6 +938,10 @@ impl TextDoc { self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; return Ok(true); } + "lineJoin" => { + self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?; + return Ok(true); + } "vertical" => { self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; return Ok(true); @@ -1423,6 +1456,8 @@ mod tests { assert!(d.is_uniform()); assert_eq!(d.style_at(2).size, 50.0); assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); + assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners"); + assert_eq!(TextDoc::default().line_join, LineJoin::Miter); assert!(d.ligatures); assert_eq!(d.kerning, Kerning::Metrics); // Round trip with runs. diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index e3264e3b7..4925460a9 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool { } /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). -/// `label` names the property in warnings. +/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and +/// the remaining keys carry the animation the renderer shows. pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { - let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) }; + let muted = p.keys.iter().any(|k| k.muted); + if muted { + ex.warn(format!("{label}: muted keyframes are not exported")); + } + let active: Vec; + let keys: &[Keyframe] = if muted { + active = p.keys.iter().filter(|k| !k.muted).cloned().collect(); + &active + } else { + &p.keys + }; + let mut out = + if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { out["x"] = json!(e.text); @@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js out } -fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec { - let keys = &p.keys; - let spatial = is_spatial(p); +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || p.hold_only { + if k.out_interp == Interp::Hold || hold_only { o["h"] = json!(1); } out.push(o); continue; } - if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() { + if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { o["h"] = json!(1); out.push(o); continue; diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index b2eeece49..6d7cc1f5a 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -47,7 +47,9 @@ fn s<'a>(j: &'a Json, k: &str) -> Option<&'a str> { fn hex_color(sc: &str) -> [f32; 3] { let h = sc.trim_start_matches('#'); - let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0; + let c = |i: usize| { + h.get(i..i + 2).filter(|x| x.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0) as f32 / 255.0 + }; [c(0), c(2), c(4)] } @@ -141,7 +143,9 @@ pub fn import( let Some(id) = s(a, "id") else { continue }; if a.get("layers").is_some() { let folder = cx.folder(project); - let (aw, ah) = (f(a, "w").map(|v| v as u32).unwrap_or(w), f(a, "h").map(|v| v as u32).unwrap_or(h)); + let layer = precomp_layer_size(&doc, &assets, id); + let aw = f(a, "w").map(|v| v as u32).or(layer.map(|l| l.0)).unwrap_or(w); + let ah = f(a, "h").map(|v| v as u32).or(layer.map(|l| l.1)).unwrap_or(h); let arate = f(a, "fr").map(FrameRate::from_f64).unwrap_or(rate); let c = Comp::new(aw, ah, arate, duration); let nm = s(a, "nm").unwrap_or(id).to_string(); @@ -240,6 +244,19 @@ pub fn import( Ok(ImportResult { comp: id, items: cx.items, warnings: cx.im.warnings }) } +/// The `w`/`h` of the first precomp layer, in the main comp or in any asset, that shows asset `id`. +/// lottie-spec gives a precomposition asset no size: the layer's `w`/`h` are its clipping rect. +fn precomp_layer_size(doc: &Json, assets: &[Json], id: &str) -> Option<(u32, u32)> { + let lists = doc.get("layers").into_iter().chain(assets.iter().filter_map(|a| a.get("layers"))); + lists.filter_map(Json::as_array).flatten().find_map(|l| { + if l.get("ty").and_then(Json::as_u64) != Some(0) || s(l, "refId") != Some(id) { + return None; + } + let (lw, lh) = f(l, "w").zip(f(l, "h"))?; + Some((lw.round().max(1.0) as u32, lh.round().max(1.0) as u32)) + }) +} + fn transform(im: &Im, ks: &Json, tr: &mut PropGroup, next: &mut u64) { let set = |tr: &mut PropGroup, m: &str, j: Option<&Json>, conv: &dyn Fn(&Json) -> Option| { if let (Some(j), Some(p)) = (j, tr.get_mut(m)) { @@ -399,7 +416,14 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ l.in_point = tb.tick(ip); l.out_point = tb.tick(op.max(ip)); l.start_time = tb.tick(f(j, "st").unwrap_or(0.0)); - l.stretch = f(j, "sr").unwrap_or(1.0) * 100.0; + // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. + let sr = f(j, "sr").unwrap_or(1.0); + l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { + sr * 100.0 + } else { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); + 100.0 + }; if j.get("ddd").and_then(Json::as_u64) == Some(1) { l.switches.three_d = true; } @@ -485,3 +509,15 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ } } } + +#[cfg(test)] +mod hex_color_tests { + use super::hex_color; + + #[test] + fn hex_color_rejects_plus_sign() { + assert_eq!(hex_color("#+f0000"), [0.0, 0.0, 0.0]); + assert_eq!(hex_color("#zz0000"), [0.0, 0.0, 0.0]); + assert_eq!(hex_color("#ff0000"), [1.0, 0.0, 0.0]); + } +} diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 1283a2497..5b9d29970 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -3,8 +3,8 @@ use effectcraft_color::BlendMode; use effectcraft_keyframe::{Gradient, ShapePath, Value}; -use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{Node, PropGroup}; +use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; +use effectcraft_project::{Node, ParamUi, PropGroup}; use serde_json::{Map, Value as Json, json}; use crate::anim::{self, Ex, Im, export_prop, import_prop}; @@ -113,6 +113,9 @@ impl Out<'_, '_> { fn f(&self, m: &str) -> f64 { self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0) } + fn flag(&self, m: &str) -> bool { + self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false) + } fn animated(&self, m: &str) -> bool { self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression()) } @@ -232,7 +235,20 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve dir(&mut o); o.put("s", "size", &anim::arr2); o.put("p", "position", &anim::arr2); - o.put("r", "roundness", &anim::num); + if o.flag("perCorner") { + let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; + let radii = names.map(|m| o.f(m)); + // Lottie `rc` has one `r`: four equal static radii export as that value. + // `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number. + if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { + o.raw("r", json!({"a": 0, "k": r(radii[0])})); + } else { + o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); + o.raw("r", json!({"a": 0, "k": 0})); + } + } else { + o.put("r", "roundness", &anim::num); + } } "el" => { dir(&mut o); @@ -356,7 +372,7 @@ fn import_blend(j: &Json, g: &mut PropGroup) { } } -fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) { +fn import_stroke(im: &Im, ids: &mut Ids, j: &Json, g: &mut PropGroup) { set(im, g, "width", j.get("w"), &anim::to_scalar); if let Some(lc) = int(j, "lc") { set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32); @@ -374,17 +390,32 @@ fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) { if let Some(list) = j.get("d").and_then(Json::as_array) && let Some(d) = g.sub_mut("dashes") { - let mut seen_dash = false; + // The n-th `d` and `g` are the n-th Dash/Gap pair; the extra pairs are created as the Dashes "+" does. + let (mut dashes, mut gaps) = (0, 0); for e in list { - let m = match e.get("n").and_then(Json::as_str) { - Some("d") if !seen_dash => { - seen_dash = true; - "dash" + let (n, pair) = match e.get("n").and_then(Json::as_str) { + Some("d") => (&mut dashes, 0), + Some("g") => (&mut gaps, 1), + Some("o") => { + set(im, d, "offset", e.get("v"), &anim::to_scalar); + continue; } - Some("g") => "gap", - Some("o") => "offset", _ => continue, }; + let k = *n; + *n += 1; + let m = match k { + 0 => ["dash", "gap"][pair], + _ => { + let Some(&(dm, gm)) = EXTRA_DASHES.get(k - 1) else { continue }; + if d.get(dm).is_none() { + let at = d.children.iter().position(|c| c.match_id() == "offset").unwrap_or(d.children.len()); + d.children.insert(at, ids.prop(gm, &format!("Gap {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into()); + d.children.insert(at, ids.prop(dm, &format!("Dash {}", k + 1), Value::Scalar(0.0)).with_ui(ParamUi::Pixels).into()); + } + [dm, gm][pair] + } + }; set(im, d, m, e.get("v"), &anim::to_scalar); } } @@ -432,6 +463,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { @@ -474,7 +510,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec Vec Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() }; + // Four equal static radii: `rc.r` carries the value, no warning. + let (p, cid) = shape([12.0; 4], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0})); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); + // Mixed radii: not representable, roundness 0 exported with a warning. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0})); + assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); + // The checkbox off exports the single Roundness and ignores the four values. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"]["k"], json!(5.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..a4070297b 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } @@ -21,12 +22,13 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-aac = { workspace = true } filmcraft-isobmff = { workspace = true } +filmcraft-matroska = { workspace = true } +filmcraft-mpegts = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } -# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. -symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs new file mode 100644 index 000000000..625c37b43 --- /dev/null +++ b/crates/media/src/aac.rs @@ -0,0 +1,116 @@ +//! The AudioSpecificConfig of the AAC track FilmCraft plays from a movie, from the containers that carry +//! it outside the audio frames: MP4 / MOV (`esds`), Matroska (`CodecPrivate`) and LATM in MPEG +//! TS / PS (its StreamMuxConfig). FilmCraft's other AAC is ADTS (in MPEG TS / PS or a raw `.aac`), +//! whose channel configurations FilmCraft decodes (1 to 7) its channel count tells apart +//! ([`crate::downmix::Order::Aac`]). + +use std::io; + +use filmcraft_aac::AudioSpecificConfig; +use filmcraft_media::ByteReader; + +/// How far into an MPEG TS / PS file the audio streams are looked for: the stream is indexed as +/// far as this, not through the whole file. +const MPEG_HEAD: u64 = 8 << 20; + +/// The configuration of the audio track FilmCraft plays, if it is AAC: from an MP4 / MOV `header` +/// (its ftyp and moov, or the whole file) and `file`, or else from `file`, read as Matroska or +/// MPEG TS / PS. +pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Option { + let asc = match header.and_then(|h| mp4(h, file)) { + Some(asc) => asc, + None => { + let file = file?; + let mut magic = [0u8; 4]; + file.read_at(0, &mut magic).ok()?; + if magic == [0x1A, 0x45, 0xDF, 0xA3] { matroska(file)? } else { latm(file)? } + } + }; + AudioSpecificConfig::parse(&asc).ok() +} + +/// The track FilmCraft's MP4 source plays: the first audio track with samples, a sample +/// description and a timescale, read from the whole file as it is (a fragmented movie's samples +/// are in its fragments, not the `moov`). +fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> { + let mp4 = match file { + Some(f) => filmcraft_isobmff::open(Head(f, f.len())), + None => filmcraft_isobmff::open(header), + }; + let mp4 = mp4.ok()?; + let playable = + |t: &&filmcraft_isobmff::Track| t.kind == filmcraft_isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0; + let track = mp4.tracks.iter().find(playable)?; + match &track.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), + _ => None, + } +} + +/// The track FilmCraft's Matroska source plays: the first audio track with frames, found by +/// indexing the clusters as that source does. +fn matroska(file: &dyn ByteReader) -> Option> { + let mkv = filmcraft_matroska::open(Head(file, file.len())).ok()?; + match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio && !t.samples.is_empty())?.codec { + filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()), + _ => None, + } +} + +/// The configuration of the stream FilmCraft's MPEG source plays: the first audio stream whose +/// first unit it can read. Only AAC is asked about, so the streams before that one were refused +/// whatever their codec, and an ADTS stream played has no configuration outside its frames. +fn latm(file: &dyn ByteReader) -> Option> { + use filmcraft_mpegts::Codec; + let head = Head(file, file.len().min(MPEG_HEAD)); + let mpeg = filmcraft_mpegts::open(&head).ok()?; + let audio = mpeg.streams.iter().enumerate().filter(|(_, s)| s.kind() == filmcraft_mpegts::Kind::Audio && !s.units.is_empty()); + let played = audio.take(filmcraft_media::MAX_AUDIO_STREAMS).find_map(|(i, s)| { + let first = mpeg.read_unit(&head, i, 0).ok()?; + match s.codec { + Codec::AacAdts => filmcraft_mpegts::frame_info(&s.codec, &first).map(|_| None), + Codec::AacLatm => filmcraft_codecs::audio::latm_config(&first).map(|c| Some(c.asc)), + _ => None, + } + }); + played? +} + +/// The first `.1` bytes of a file, as the byte source FilmCraft's demuxers read. +struct Head<'a>(&'a dyn ByteReader, u64); + +impl Head<'_> { + fn read(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + match offset.checked_add(buf.len() as u64) { + Some(end) if end <= self.1 => self.0.read_at(offset, buf), + _ => Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read past the end")), + } + } +} + +impl filmcraft_isobmff::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + +impl filmcraft_matroska::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + +impl filmcraft_mpegts::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 2ef417dd7..09bb1e1f6 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -10,16 +10,19 @@ //! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and //! stereo is untouched. //! -//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, -//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, -//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC -//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables. -//! More than 8 channels carry no known layout: only the first two (front L/R) are used. +//! A decoded channel's gains come from where it plays. WAVE / SMPTE order by channel count is what +//! FilmCraft's AC-3, PCM, FLAC and Opus readers deliver: 3 = L R C, 4 = quad L R Ls Rs, +//! 5 = L R C Ls Rs, 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC +//! arrives in an order its configuration decides ([`aac_order`]). More than 8 channels in WAVE +//! order carry no known layout: only the first two (front L/R) are used. use std::f32::consts::FRAC_1_SQRT_2; +use std::sync::Arc; + +use filmcraft_aac::{AudioSpecificConfig, ProgramConfig}; /// A source channel's contribution to the left and right outputs, before normalisation. -type Pair = [f32; 2]; +pub type Pair = [f32; 2]; const FL: Pair = [1.0, 0.0]; const FR: Pair = [0.0, 1.0]; @@ -29,33 +32,18 @@ const SL: Pair = [FRAC_1_SQRT_2, 0.0]; const SR: Pair = [0.0, FRAC_1_SQRT_2]; const CS: Pair = [0.5, 0.5]; -/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised. -/// `aac` selects AAC's native channel order. Empty for zero channels. -pub fn stereo_gains(count: usize, aac: bool) -> Vec { - let raw: &[Pair] = match (count, aac) { - (0, _) => &[], - (1, _) => &[[1.0, 1.0]], - (2, _) => &[FL, FR], - (3, false) => &[FL, FR, FC], - (3, true) => &[FC, FL, FR], - (4, false) => &[FL, FR, SL, SR], - (4, true) => &[FC, FL, FR, CS], - (5, false) => &[FL, FR, FC, SL, SR], - (5, true) => &[FC, FL, FR, SL, SR], - (6, false) => &[FL, FR, FC, LFE, SL, SR], - (6, true) => &[FC, FL, FR, SL, SR, LFE], - (7, false) => &[FL, FR, FC, LFE, CS, SL, SR], - (7, true) => &[FC, FL, FR, SL, SR, CS, LFE], - (8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR], - (8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE], - _ => &[FL, FR], - }; +/// AAC channel configuration 7 as FilmCraft decodes it (element order, not reordered). +const CONFIG_7: [Pair; 8] = [FC, FL, FR, SL, SR, SL, SR, LFE]; +/// AAC channel configuration 4 (C L R Cs), which FilmCraft reorders to L R C Cs. +const CONFIG_4: [Pair; 4] = [FL, FR, FC, CS]; + +/// `raw` scaled by the reciprocal of its larger row sum when that exceeds 1 (more than two +/// channels): full-scale input can never exceed full scale. +fn normalised(raw: &[Pair]) -> Vec { let mut out = raw.to_vec(); - // Channels beyond a known layout (more than 8) are silent. - out.resize(count, [0.0, 0.0]); let peak = out.iter().map(|g| g[0]).sum::().max(out.iter().map(|g| g[1]).sum::()); // Mono is deliberately left at unity on both sides; so is anything already within range. - if count > 2 && peak > 1.0 { + if out.len() > 2 && peak > 1.0 { for g in &mut out { g[0] /= peak; g[1] /= peak; @@ -64,11 +52,109 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } -/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a -/// channel is too short for count as silence, extra frames in `out` stay as they are. -pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = stereo_gains(channels.len(), aac); - for (ch, g) in channels.iter().zip(&gains) { +/// The per-channel `[left, right]` gains for `count` channels in WAVE / SMPTE order, normalised. +/// Empty for zero channels. +pub fn stereo_gains(count: usize) -> Vec { + let raw: &[Pair] = match count { + 0 => &[], + 1 => &[[1.0, 1.0]], + 2 => &[FL, FR], + 3 => &[FL, FR, FC], + 4 => &[FL, FR, SL, SR], + 5 => &[FL, FR, FC, SL, SR], + 6 => &[FL, FR, FC, LFE, SL, SR], + 7 => &[FL, FR, FC, LFE, CS, SL, SR], + 8 => &[FL, FR, FC, LFE, SL, SR, SL, SR], + _ => &[FL, FR], + }; + let mut raw = raw.to_vec(); + // Channels beyond a known layout (more than 8) are silent. + raw.resize(count, [0.0, 0.0]); + normalised(&raw) +} + +/// Where a source's decoded channels play. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Order { + /// WAVE / SMPTE by channel count: PCM, FLAC, AC-3, Opus and the rest. + #[default] + Wav, + /// AAC whose configuration wasn't read: by channel count as the configurations 1 to 7 decode + /// (exact for ADTS, which can signal no other configuration FilmCraft decodes), 7 channels as + /// configuration 11. + Aac, + /// Each decoded channel's gains, before normalisation. + Speakers(Arc<[Pair]>), +} + +/// How FilmCraft's decoder hands over AAC with the configuration `asc`: it reorders channel +/// configurations 3 to 6, 11 and 12 to WAVE / SMPTE and leaves 7 and PCE layouts in element order. +pub fn aac_order(asc: &AudioSpecificConfig) -> Order { + match (asc.channel_config, &asc.pce) { + (4, _) => Order::Speakers(CONFIG_4.into()), + (1..=6 | 11 | 12, _) => Order::Wav, + (7, _) => Order::Speakers(CONFIG_7.into()), + (0, Some(pce)) => Order::Speakers(pce_speakers(pce).into()), + _ => Order::Aac, + } +} + +/// ffmpeg's encoder fills a program config element's elements with its layout's channels in its +/// own order (L R C LFE …), and for these structures that order doesn't match the element +/// positions: an LFE in a side SCE, a back centre in a side pair. Front, side and back element +/// types (`true` for a pair) and the channels of the ffmpeg layout with that structure (the first +/// ffmpeg 8.1 lists, where several share it: 6.1 for 6.1(back) and 6.1(front), 7.1(wide) for +/// 7.1(wide-side)). +const FFMPEG_PCE: [(&[bool], &[bool], &[bool], &[Pair]); 6] = [ + (&[true, false], &[false], &[false], &[FL, FR, FC, LFE, CS]), + (&[true, false], &[false], &[true], &[FL, FR, FC, LFE, SL, SR]), + (&[true, false], &[true], &[false], &[FL, FR, FC, CS, SL, SR]), + (&[true, false], &[false], &[true, false], &[FL, FR, FC, LFE, CS, SL, SR]), + (&[true, false], &[false], &[true, true], &[FL, FR, FC, LFE, SL, SR, FL, FR]), + (&[true, false], &[true], &[true, false], &[FL, FR, FC, SL, SR, CS, SL, SR]), +]; + +/// The channels of a PCE layout in decoded (element) order: ffmpeg's for its structures above +/// (it names itself in the PCE's comment), otherwise by position (ISO/IEC 14496-3's program config element): +/// front pairs left and right, a front single the centre, side and back pairs surrounds, a side +/// or back single a back centre. +fn pce_speakers(pce: &ProgramConfig) -> Vec { + let pairs = |els: &[(bool, u8)]| els.iter().map(|e| e.0).collect::>(); + let (front, side, back) = (pairs(&pce.front), pairs(&pce.side), pairs(&pce.back)); + if pce.comment.starts_with(b"Lavc") && pce.lfe.is_empty() { + let known = FFMPEG_PCE.iter().find(|(f, s, b, _)| *f == front.as_slice() && *s == side.as_slice() && *b == back.as_slice()); + if let Some((.., speakers)) = known { + return speakers.to_vec(); + } + } + let mut out = Vec::new(); + for pair in front { + out.extend_from_slice(if pair { &[FL, FR] } else { &[FC] }); + } + for pair in side.into_iter().chain(back) { + out.extend_from_slice(if pair { &[SL, SR] } else { &[CS] }); + } + out.extend(pce.lfe.iter().map(|_| LFE)); + out +} + +/// The gains for `count` channels arriving in `order`. FilmCraft decodes as many channels as the +/// container names, the first ones of the layout where that is fewer (MPEG-TS LATM names 7 of +/// configuration 7's 8, ffmpeg 4.2 MP4s 2 of a PCE's); more go by count. +pub fn decoded_gains(count: usize, order: &Order) -> Vec { + match order { + Order::Speakers(s) if count <= s.len() => normalised(s.get(..count).unwrap_or_default()), + Order::Aac if count == 4 => normalised(&CONFIG_4), + Order::Aac if count == 8 => normalised(&CONFIG_7), + _ => stereo_gains(count), + } +} + +/// Downmix planar `channels` arriving in `order` to interleaved stereo in `out` (`out.len() / 2` +/// frames); frames a channel is too short for count as silence, extra frames in `out` stay as +/// they are. +pub fn downmix_into(channels: &[Vec], order: &Order, out: &mut [f32]) { + for (ch, g) in channels.iter().zip(&decoded_gains(channels.len(), order)) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -85,49 +171,105 @@ mod tests { #[test] fn every_channel_count_has_a_bounded_defined_matrix() { - for aac in [false, true] { + let pce = Order::Speakers([FL, FR, FC, FC, SL, SR, SL, SR, CS, LFE].into()); + for order in [Order::Wav, Order::Aac, Order::Speakers(CONFIG_7.into()), pce] { for n in 0..40usize { - let g = stereo_gains(n, aac); + let g = decoded_gains(n, &order); assert_eq!(g.len(), n); let (l, r) = (g.iter().map(|p| p[0]).sum::(), g.iter().map(|p| p[1]).sum::()); if n > 2 { - assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}"); + assert!(l <= 1.0001 && r <= 1.0001, "{n} channels ({order:?}): {l} {r}"); assert!(l > 0.0 && r > 0.0); } } } } + /// AudioSpecificConfigs as ffmpeg 8.1 and FilmCraft's encoder write them (48 kHz AAC-LC). + const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500"; + const PCE_FILMCRAFT_61: &str = "118004c845000108840000"; + + fn order(hex: &str) -> Order { + let asc: Vec = (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect(); + aac_order(&AudioSpecificConfig::parse(&asc).expect("an AudioSpecificConfig")) + } + + fn speakers(order: &Order) -> Vec { + match order { + Order::Speakers(s) => s.to_vec(), + o => panic!("{o:?} names no speakers"), + } + } + + #[test] + fn aac_order_follows_the_channel_configuration() { + for n in [1, 2, 3, 5, 6, 11, 12] { + assert_eq!(aac_order(&AudioSpecificConfig::lc(3, n)), Order::Wav, "configuration {n}"); + } + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 4))), [FL, FR, FC, CS]); + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 7))), [FC, FL, FR, SL, SR, SL, SR, LFE]); + assert_eq!(speakers(&order(PCE_FFMPEG_61)), [FL, FR, FC, LFE, CS, SL, SR], "ffmpeg's 6.1 carries the LFE in a side SCE"); + assert_eq!(speakers(&order(PCE_FILMCRAFT_61)), [FC, FL, FR, SL, SR, CS, LFE], "a centre-first PCE by position"); + } + + #[test] + fn only_ffmpeg_pce_structures_it_misplaces_take_its_channel_order() { + let pce = |comment: &[u8], side: Vec<(bool, u8)>, lfe: Vec| ProgramConfig { + front: vec![(true, 0), (false, 0)], + side, + back: vec![(true, 1)], + lfe, + comment: comment.to_vec(), + ..Default::default() + }; + // 5.1(side): ffmpeg's LFE sits in the side SCE + assert_eq!(pce_speakers(&pce(b"Lavc58.54.100", vec![(false, 1)], vec![])), [FL, FR, FC, LFE, SL, SR]); + // the same structure from another encoder: a back centre by position + assert_eq!(pce_speakers(&pce(b"", vec![(false, 1)], vec![])), [FL, FR, FC, CS, SL, SR]); + // 7.0: ffmpeg's order matches the positions + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(true, 2)], vec![])), [FL, FR, FC, SL, SR, SL, SR]); + // a real LFE element is never ffmpeg's misplaced one + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(false, 1)], vec![0])), [FL, FR, FC, CS, SL, SR, LFE]); + } + + #[test] + fn a_decode_of_fewer_channels_takes_the_first_of_the_layout_and_more_go_by_count() { + assert_eq!(decoded_gains(2, &order(PCE_FILMCRAFT_61)), [FC, FL]); + assert_eq!(decoded_gains(7, &Order::Speakers(CONFIG_7.into())), normalised(&CONFIG_7[..7])); + assert_eq!(decoded_gains(9, &Order::Speakers(CONFIG_7.into())), stereo_gains(9)); + assert_eq!(decoded_gains(4, &Order::Aac), normalised(&[FL, FR, FC, CS])); + assert_eq!(decoded_gains(8, &Order::Aac), normalised(&CONFIG_7)); + for n in [3, 5, 6, 7] { + assert_eq!(decoded_gains(n, &Order::Aac), stereo_gains(n), "{n} channels"); + } + } + #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); - let g = stereo_gains(6, false); + let g = stereo_gains(6); assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0); assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6); assert_eq!(g[3], [0.0, 0.0], "LFE is left out"); assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0); assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0); - // AAC order: the centre is first, LFE last - let a = stereo_gains(6, true); - assert_eq!((a[0], a[5]), (g[2], g[3])); - assert_eq!((a[1], a[2]), (g[0], g[1])); } #[test] fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() { - assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]); - assert_eq!(stereo_gains(2, true), vec![FL, FR]); - let g = stereo_gains(12, false); + assert_eq!(stereo_gains(1), vec![[1.0, 1.0]]); + assert_eq!(stereo_gains(2), vec![FL, FR]); + let g = stereo_gains(12); assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0])); } #[test] fn ragged_and_empty_input_never_panics() { let mut out = vec![0.0; 8]; - downmix_into(&[], false, &mut out); - downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out); + downmix_into(&[], &Order::Wav, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], &Order::Aac, &mut out); assert!(out.iter().all(|v| v.is_finite())); let mut none: [f32; 0] = []; - downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none); + downmix_into(&vec![vec![1.0; 3]; 7], &Order::Speakers(CONFIG_7.into()), &mut none); } } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 795da99e5..d372ec849 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_raster::AuxChannels; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; +/// The largest display window decoded (16384²; vector footage and Continuously Rasterize +/// stop at the same size). +const MAX_DISPLAY_PIXELS: usize = 1 << 28; + fn is_depth(name: &str) -> bool { let last = name.rsplit('.').next().unwrap_or(name); last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") @@ -75,7 +79,9 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { } }; collect_meta(&img.attributes.other); - let n = w.checked_mul(h)?; + // Each channel is a display-window plane: a 60000² window over a 1×1 data window was a + // multi-gigabyte allocation per channel (#2141). + let n = w.checked_mul(h).filter(|n| *n <= MAX_DISPLAY_PIXELS)?; // The decoded image is consumed channel by channel, so each channel's samples are freed (or // moved) as its plane is made instead of the whole file being held twice (#474). for layer in img.layer_data { @@ -337,6 +343,18 @@ mod tests { assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); } + /// #2141: one pixel of data in a 60000² display window is refused, not allocated per channel. + #[test] + fn a_huge_display_window_is_refused_not_allocated() { + use exr::prelude::*; + let channels = AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5]))].into()); + let mut image = Image::from_layer(Layer::new((1, 1), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + image.attributes.display_window = IntegerBounds::new((0, 0), (60000, 60000)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + assert!(read_exr_channels(&bytes.into_inner()).is_none()); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 055c3feb0..b259c8b7f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -113,6 +113,11 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - if is_svg(path, bytes) || is_pdf_like(path, bytes) { let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); + // A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136): + // past what Continuously Rasterize can draw, the footage reports an error instead. + if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) { + return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})"))); + } let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. let _ = op; @@ -121,10 +126,32 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } +/// The largest side vector footage is drawn at, decoded or continuously rasterised. +const MAX_VECTOR_SIDE: u32 = 16384; + /// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { let doc = vector_doc(path, bytes, layer, page)?.ok()?; let (w, h) = doc.pixel_size(); - let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); + let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// #2136: an SVG that declares 30000×30000 asked for a 14 GB image and aborted the app; it + /// is now an error, and a drawable size still decodes. + #[test] + fn vector_footage_past_the_drawable_size_is_an_error_not_an_abort() { + let huge = br#""#; + let footage = probe("huge.svg", huge).unwrap().unwrap(); + let e = decode("huge.svg", huge, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("30000×30000"), "{e}"); + let small = br#""#; + let footage = probe("small.svg", small).unwrap().unwrap(); + let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap(); + assert_eq!((img.width, img.height), (40, 20)); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 9b5b491ab..e985985b7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -15,6 +15,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod aac; mod convert; pub mod downmix; mod dpx; @@ -25,6 +26,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; @@ -63,8 +65,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; -/// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; @@ -79,6 +80,22 @@ pub fn is_importable(path: &std::path::Path) -> bool { || dpx::EXTENSIONS.contains(&ext.as_str()) } +/// A whole file's bytes, refusing anything that isn't a regular file: a project can name +/// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). +pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + pub(crate) fn ext_of(path: &std::path::Path) -> String { path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } @@ -99,3 +116,20 @@ pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option effectcraft_time::FrameRate { if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 } } + +#[cfg(test)] +mod read_tests { + /// #2134: a folder (and on Unix a device) named as footage is an error, not an endless read. + #[test] + fn only_regular_files_are_read_whole() { + let dir = std::env::temp_dir(); + assert!(super::read_regular_file(&dir).is_err()); + assert!(crate::probe::probe(&dir).is_err(), "probing a folder fails instead of reading it"); + #[cfg(unix)] + assert!(super::read_regular_file(std::path::Path::new("/dev/zero")).is_err()); + let f = dir.join(format!("ec-read-regular-{}.bin", std::process::id())); + std::fs::write(&f, b"abc").unwrap(); + assert_eq!(super::read_regular_file(&f).unwrap(), b"abc"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 4820efa35..655ca4ed8 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -12,6 +12,7 @@ use filmcraft_media::{FrameRequest, SharedSource}; use image::metadata::Orientation; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; +use crate::downmix::Order; use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; @@ -67,6 +68,10 @@ const CONFORM_VERSION: u32 = 3; /// conform thread allocate all of it at once (#408). const MAX_CONFORM_SECONDS: i64 = 24 * 3600; +/// The sample rates audio is read at; a header outside them is damaged or hostile (#2141). +const MIN_SAMPLE_RATE: u32 = 1_000; +const MAX_SAMPLE_RATE: u32 = 768_000; + /// The sample frames of `footage`'s audio at `rate` Hz that a conformed file holds, `None` past /// [`MAX_CONFORM_SECONDS`]. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -174,10 +179,36 @@ struct CacheState { last: HashMap, i64>, } -/// A movie/audio source by path and the turn its picture is shown with -/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it -/// (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait +/// for it (`None`: failed to open; not retried until `forget`). +type SourceSlot = Arc>>; + +/// An opened movie/audio source, the turn its picture is shown with and the order its decoded +/// audio channels arrive in. +#[derive(Clone)] +struct Opened { + src: SharedSource, + turn: Orientation, + order: Order, +} + +impl Opened { + /// `src` with what its container says: `header` is an MP4/MOV's ftyp and moov (or the whole + /// file), `file` the file for other containers' AAC configuration. + fn new(src: SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Opened { + let turn = header.map_or(Orientation::NoTransforms, crate::probe::video_turn); + Opened { order: channel_order(&src, header, file), src, turn } + } +} + +/// The order `src`'s audio channels decode in; multichannel AAC's by its configuration, where +/// the container's header shows it ([`crate::aac::config`]). +fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Order { + match src.info().audio() { + Some(a) if a.codec.contains("AAC") && a.channels > 2 => crate::aac::config(header, file).map_or(Order::Aac, |asc| crate::downmix::aac_order(&asc)), + _ => Order::Wav, + } +} /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). type GifSlot = Arc, String>>>; @@ -510,18 +541,23 @@ impl MediaPool { /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let src = self.inner.source(&path).ok_or("the file can't be opened")?; + let Opened { src, order, .. } = self.inner.opened(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); - let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + let native = src.info().audio().map_or(rate, |a| a.sample_rate); + // The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about + // 47 GB (#2141). Real audio is 1–768 kHz. + if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) { + return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); + } if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, s0, frames, rate); + return read_stereo(&src, &order, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; - let buf = read_stereo(&src, first64, count, native)?; + let buf = read_stereo(&src, &order, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -581,20 +617,15 @@ impl Inner { if let Some(b) = lock(&self.files).get(path) { return Ok(b.clone()); } - std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) + crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } - /// The opened movie/audio source for `path`. The first caller opens it, outside the map's - /// lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF files are - /// decoded whole); callers asking for the same file meanwhile wait for that instead of opening - /// it again: a preview's frame workers, audio feeder, conform and waveform threads each - /// decoded a long FLAC in full at once (#408). - fn source(&self, path: &Arc) -> Option { - self.opened(path).map(|(src, _)| src) - } - - /// [`Self::source`] and the turn the movie's picture is shown with. - fn opened(&self, path: &Arc) -> Option<(SharedSource, Orientation)> { + /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside + /// the map's lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF + /// files are decoded whole); callers asking for the same file meanwhile wait for that instead + /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads + /// each decoded a long FLAC in full at once (#408). + fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); @@ -604,14 +635,14 @@ impl Inner { let file = std::path::Path::new(&**path); let in_place = if in_memory { None } else { crate::stream::open_source(file) }; let in_place = in_place.map(|src| { - let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); - src.map(|src| (src, turn)) + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) }); let opened = in_place.unwrap_or_else(|| { self.read(path).and_then(|bytes| { let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let turn = crate::probe::video_turn(&bytes); - filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }) }); match opened { @@ -703,9 +734,12 @@ impl Inner { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); let i = Self::frame_index(rate, t, n, footage.loop_count); - // Middle of the frame in the file's own timing (robust to timestamp rounding). + // A quarter into the frame in the file's own timing: FilmCraft rounds a request to + // the track's nearest timestamp unit (halves up), so the middle of a frame would + // land on the next one when the timescale is the frame rate; a quarter still + // tolerates timestamps rounded either way. let native = footage.native_rate.unwrap_or(rate); - let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); + let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 4); Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } @@ -842,7 +876,7 @@ impl Inner { Ok(dynamic_to_image(frame, op)) } Some(mt) => { - let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; + let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; let vf = src .video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))) .map_err(MediaError::from) @@ -941,8 +975,8 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. -fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { +/// channels, multichannel sources (channels in `order`) are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, order: &Order, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { @@ -950,8 +984,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); - crate::downmix::downmix_into(&buf.channels, aac, dst); + crate::downmix::downmix_into(&buf.channels, order, dst); Ok(out) } @@ -1218,8 +1251,8 @@ mod tests { 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. + /// A model's material library is read beside it; one named by a path to another computer is + /// looked up under its own file name beside the model, never fetched from that machine (#1274). #[test] fn model_files_on_other_computers_are_not_read() { let dir = std::env::temp_dir().join(format!("ec-model-files-{}", std::process::id())); @@ -1233,11 +1266,13 @@ mod tests { 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")); + // 192.0.2.1 is a test-net address nothing here can reach, so these passes prove the + // material library came from beside the model, not from another computer. + 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()))); + assert!(red("slashes.obj", &format!("/{}", dir.join("red.mtl").display()))); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bba682b3..6bae86f5b 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,9 +23,10 @@ pub fn probe(path: impl AsRef) -> Result { let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); correct_rate(&mut f, header.as_deref().and_then(video_rate)); + trim_to_edits(&mut f, header.as_deref().and_then(video_shown)); return Ok(f); } - let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; + let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path)); } @@ -38,7 +39,7 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf()); move |uri| { let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?; - std::fs::read(p).ok() + crate::read_regular_file(&p).ok() } } @@ -107,13 +108,45 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + let shown = video_shown(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); + trim_to_edits(&mut f, shown); Ok(f) } +/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media +/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the +/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens. +fn trim_to_edits(f: &mut Footage, shown: Option) { + if f.kind == FootageKind::Video + && let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration) + { + f.duration = shown; + } +} + +/// How long the video track's edit list shows its media (`elst` segments, movie timescale): +/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). +fn video_shown(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + edits_shown(&t.edits, file.timescale) +} + +fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { + if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { + return None; + } + let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?; + Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale))) +} + /// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's /// own estimate misreads it. fn correct_rate(f: &mut Footage, rate: Option) { @@ -142,7 +175,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - (_, None) => FootageKind::Audio, }; let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate)); - let codec = match (v, info.audio.as_ref()) { + let codec = match (v, info.audio()) { (Some(v), _) => v.codec.clone(), (None, Some(a)) => a.codec.clone(), _ => String::new(), @@ -157,7 +190,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - native_rate: None, duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) }, has_video: v.is_some(), - has_audio: info.audio.is_some(), + has_audio: info.has_audio(), alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, premul_color: [0.0; 3], loop_count: 1, @@ -262,12 +295,19 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other -/// containers have none. +/// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this +/// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; + // A pure rotation is already applied by FilmCraft's decoder (frames and size come out + // upright); only the headers it leaves as stored (mirrored, scaled, sheared) are turned here. + if track.display_rotation().is_some() { + return Orientation::NoTransforms; + } + let m = track.matrix; // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. let (a, b) = (m[0] as i64, m[1] as i64); match (a.abs() >= b.abs(), a >= 0, b >= 0) { @@ -319,6 +359,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a + /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. + #[test] + fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { + let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let shown = edits_shown(&[edit(3000, 1024)], 1000); + assert_eq!(shown, Some(Tick::from_units(3, 1))); + let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; + trim_to_edits(&mut f, shown); + assert_eq!(f.duration, Tick::from_units(75, 25)); + // Never longer than the reader's duration, and only for video. + trim_to_edits(&mut f, Some(Tick::from_units(4, 1))); + assert_eq!(f.duration, Tick::from_units(75, 25)); + assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None); + assert_eq!(edits_shown(&[], 1000), None); + assert_eq!(edits_shown(&[edit(3000, 0)], 0), None); + } + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 447e239d1..80db21fc4 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -85,6 +85,17 @@ pub(crate) fn open_source(_path: &Path) -> Option> { None } +/// `path` read in place (`None` when it can't be opened, or without a native file system). +#[cfg(any(unix, windows))] +pub(crate) fn file_reader(path: &Path) -> Option> { + Some(Box::new(FileReader::open(path).ok()?)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn file_reader(_path: &Path) -> Option> { + None +} + /// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display /// matrix needs), without reading the samples: top-level box headers are walked with positional /// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large. diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..ca40824b6 --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,517 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; + let start = at.saturating_add(8); + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); + for m in xmp_markers(xmp, rate) { + if seen.insert((m.comment.clone(), m.time)) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { + match &id { + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); + } + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } +} diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs new file mode 100644 index 000000000..fb3e078a6 --- /dev/null +++ b/crates/media/tests/it/aac_layout.rs @@ -0,0 +1,422 @@ +//! Multichannel AAC reaches stereo by where each channel plays, whatever order its channel +//! configuration decodes in (#1701). Fixtures are tones encoded with FilmCraft's AAC encoder (no +//! ffmpeg needed, so nothing is skipped), with the configuration rewritten where the coded +//! elements fit another: configurations 11 and 12 code the same elements as the encoder's 7- and +//! 8-channel output, and a program config element can name any of them. + +use std::f64::consts::PI; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe, probe_bytes}; +use effectcraft_time::Tick; +use filmcraft_aac::{AudioSpecificConfig, Encoder, EncoderConfig, ProgramConfig}; + +const RATE: u32 = 48_000; +/// Tone frequencies by channel; an LFE gets [`LFE_TONE`], which its element can carry. +const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700]; +const LFE_TONE: u32 = 60; +const AMP: f32 = 0.25; +const K: f64 = std::f64::consts::FRAC_1_SQRT_2; + +/// A channel's content, by where it plays. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ch { + L, + R, + C, + Lfe, + Ls, + Rs, + Cs, +} + +impl Ch { + /// ITU-R BS.775 gains into `[left, right]`, before normalisation. + fn gains(self) -> [f64; 2] { + match self { + Ch::L => [1.0, 0.0], + Ch::R => [0.0, 1.0], + Ch::C => [K, K], + Ch::Lfe => [0.0, 0.0], + Ch::Ls => [K, 0.0], + Ch::Rs => [0.0, K], + Ch::Cs => [0.5, 0.5], + } + } +} + +struct Case { + name: &'static str, + /// What the encoder's input channels carry (its element order: C, L R, …, LFE last). + content: &'static [Ch], + /// The AudioSpecificConfig written to the container instead of the encoder's, if any. + asc: Option, +} + +fn lc(config: u8) -> Option { + Some(AudioSpecificConfig::lc(3, config)) +} + +fn pce(front: Vec<(bool, u8)>, side: Vec<(bool, u8)>, back: Vec<(bool, u8)>, lfe: Vec, comment: &[u8]) -> Option { + let pce = ProgramConfig { object_type: 1, sf_index: 3, front, side, back, lfe, comment: comment.to_vec(), ..Default::default() }; + Some(AudioSpecificConfig { pce: Some(pce), ..AudioSpecificConfig::lc(3, 0) }) +} + +use Ch::*; + +fn cases() -> Vec { + vec![ + Case { name: "config3", content: &[C, L, R], asc: None }, + Case { name: "config4", content: &[C, L, R, Cs], asc: None }, + Case { name: "config5", content: &[C, L, R, Ls, Rs], asc: None }, + Case { name: "config6", content: &[C, L, R, Ls, Rs, Lfe], asc: None }, + // configuration 7 as ffmpeg writes 7.1: the second and third pairs are surrounds + Case { name: "config7", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: None }, + Case { name: "config11", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: lc(11) }, + Case { name: "config12", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: lc(12) }, + // the encoder's own 6.1: a PCE listing the centre first + Case { name: "pce61-centre-first", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: None }, + // ffmpeg's 6.1 PCE: front pair and centre, a side SCE, a back pair and SCE, no LFE + // element, its channels in ffmpeg's order L R C LFE Cs Ls Rs. The decoder places the + // encoder's SCE 0 in the front SCE, pair 0 front, pair 1 back, SCE 1 in the back SCE (tag + // 1) and the LFE in the side SCE left over (tag 5). + Case { + name: "pce61-ffmpeg", + content: &[C, L, R, Cs, Ls, Rs, Lfe], + asc: pce(vec![(true, 0), (false, 0)], vec![(false, 5)], vec![(true, 1), (false, 1)], vec![], b"Lavc62.28.102"), + }, + ] +} + +fn freq(content: &[Ch], i: usize) -> u32 { + if content.get(i) == Some(&Lfe) { LFE_TONE } else { FREQS[i] } +} + +/// Two seconds of `content`'s tones encoded as AAC: the AudioSpecificConfig and access units. +fn encode(content: &[Ch], adts: bool) -> (Vec, Vec>) { + let n = content.len(); + let mut cfg = EncoderConfig::cbr(RATE, n, 96_000 * n as u32); + cfg.adts = adts; + let mut enc = Encoder::new(cfg).expect("encoder"); + let planes: Vec> = + (0..n).map(|c| (0..2 * RATE).map(|i| AMP * (2.0 * PI * f64::from(freq(content, c)) * f64::from(i) / f64::from(RATE)).sin() as f32).collect()).collect(); + let refs: Vec<&[f32]> = planes.iter().map(Vec::as_slice).collect(); + let mut units = enc.encode(&refs); + units.extend(enc.flush()); + (enc.audio_specific_config(), units) +} + +fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mp4_after(None, asc, channels, units) +} + +/// As [`mp4`], after an audio track with the configuration and channel count `empty` and no +/// samples, if any. +fn mp4_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); + if let Some((asc, channels)) = empty { + w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("empty track"); + } + let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("track"); + for u in units { + w.write_sample(t, WriteSample { data: u, duration: 1024, composition_offset: 0, is_sync: true }).expect("sample"); + } + w.finish().expect("finish").into_inner() +} + +fn mkv(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mkv_after(None, asc, channels, units) +} + +/// As [`mkv`], after an audio track with the configuration and channel count `empty` and no +/// frames, if any. +fn mkv_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + let track = |asc: Vec, channels: u32| { + let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); + t.codec_private = asc; + t.audio = Some((f64::from(RATE), u64::from(channels), None)); + t + }; + let tracks: Vec = empty.into_iter().map(|(asc, n)| track(asc, n)).chain([track(asc, channels)]).collect(); + let played = tracks.len() - 1; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), tracks, MuxOptions::default()).expect("writer"); + let frame_ns = 1_000_000_000 * 1024 / i64::from(RATE); + for (i, u) in units.iter().enumerate() { + w.write_frame(played, i as i64 * frame_ns, true, u, Some(frame_ns as u64)).expect("frame"); + } + w.finish().expect("finish").into_inner() +} + +/// The fixture written to disk as `name`. +fn write(name: &str, bytes: &[u8]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("effectcraft-aac-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(name); + std::fs::write(&p, bytes).expect("write"); + p +} + +/// Amplitude of the tone at `freq` in one side of interleaved stereo. +fn amp(s: &[f32], side: usize, freq: u32) -> f64 { + let (mut re, mut im, mut n) = (0.0, 0.0, 0usize); + for (i, f) in s.as_chunks::<2>().0.iter().enumerate() { + let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE); + re += f64::from(f[side]) * w.cos(); + im += f64::from(f[side]) * w.sin(); + n += 1; + } + 2.0 * (re * re + im * im).sqrt() / n.max(1) as f64 +} + +/// Stereo of one second from 0.5 s in, through the media pool as footage reads it. +fn mix(pool: &MediaPool, footage: &effectcraft_project::Footage) -> Vec { + pool.audio_samples(footage, Tick::from_seconds_f64(0.5), RATE as usize, RATE) +} + +/// Each channel of `content` reaches each side at its BS.775 gain, normalised by the larger +/// side's sum (an LFE nowhere). +fn check(label: &str, content: &[Ch], s: &[f32]) { + let raw: Vec<[f64; 2]> = content.iter().map(|c| c.gains()).collect(); + let norm = raw.iter().map(|g| g[0]).sum::().max(raw.iter().map(|g| g[1]).sum::()).max(1.0); + for (i, (ch, g)) in content.iter().zip(&raw).enumerate() { + for side in 0..2 { + let (got, want) = (amp(s, side, freq(content, i)), f64::from(AMP) * g[side] / norm); + assert!( + (got - want).abs() <= f64::from(AMP) * 0.05, + "{label}: {ch:?} ({} Hz) -> {} is {got:.4}, expected {want:.4}", + freq(content, i), + ["L", "R"][side] + ); + } + } +} + +/// The LFE tone survives encoding, so its absence from the mix means it was left out. +fn lfe_is_coded(case: &Case, bytes: Vec) { + if !case.content.contains(&Lfe) { + return; + } + let src = filmcraft_codecs::open_bytes("lfe.mp4", bytes.into()).expect("open"); + let buf = src.audio(24_000, RATE as usize, RATE).expect("decode"); + let best = buf.channels.iter().map(|ch| amp(&ch.iter().flat_map(|&v| [v, v]).collect::>(), 0, LFE_TONE)).fold(0.0, f64::max); + assert!(best > 0.8 * f64::from(AMP), "{}: the LFE tone is coded ({best:.4})", case.name); +} + +#[test] +fn every_aac_layout_mixes_by_speaker_in_mp4() { + for case in cases() { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let bytes = mp4(asc, case.content.len() as u32, &units); + lfe_is_coded(&case, bytes.clone()); + let p = write(&format!("{}.mp4", case.name), &bytes); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + // in memory (`add_bytes`) as well as in place + let (path, bytes): (String, Arc<[u8]>) = (format!("/mem/{}.mp4", case.name), bytes.into()); + let pool = MediaPool::new(); + pool.add_bytes(&path, bytes.clone()); + check(&format!("{} in memory", case.name), case.content, &mix(&pool, &probe_bytes(&path, bytes).expect("probe"))); + } +} + +#[test] +fn matroska_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| matches!(c.name, "config12" | "pce61-centre-first" | "pce61-ffmpeg")) { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{}.mkv", case.name), &mkv(asc, case.content.len() as u32, &units)); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// ADTS signals configurations 1 to 7 only, which the channel count tells apart. +#[test] +fn adts_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| c.asc.is_none() && c.content.len() != 7) { + let (_, units) = encode(case.content, true); + let p = write(&format!("{}.aac", case.name), &units.concat()); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// Audio plays from the first track FilmCraft can play, so an audio track with no samples before +/// it lends it no configuration. Each case decodes in another order than the empty track's +/// configuration would (FilmCraft reorders 12 to WAVE / SMPTE but not 7, and 11 but not a PCE). +#[test] +fn an_empty_audio_track_lends_its_configuration_to_no_other() { + for (name, empty) in [("config12", 7), ("pce61-centre-first", 11)] { + let case = cases().into_iter().find(|c| c.name == name).expect("case"); + let n = case.content.len() as u32; + let empty = Some((AudioSpecificConfig::lc(3, empty).to_bytes(), n)); + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{name}-after-empty.mp4"), &mp4_after(empty.clone(), asc.clone(), n, &units)); + check(&format!("{name} MP4"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + let p = write(&format!("{name}-after-empty.mkv"), &mkv_after(empty, asc, n, &units)); + check(&format!("{name} Matroska"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// A transport stream plays its first audio stream and mixes by that stream's configuration, +/// whatever follows it. ADTS of configuration 7 decodes in element order, LATM of configuration +/// 12 reordered to WAVE / SMPTE; LATM of configuration 7 names 7 channels, which mix as +/// configuration 7's rather than as 7 channels by count. (FilmCraft decodes LATM of configuration +/// 12 as 12 channels, so it isn't played here.) +#[test] +fn mpeg_aac_mixes_by_the_stream_played() { + let case = cases().into_iter().find(|c| c.name == "config7").expect("case"); + let (_, adts) = encode(case.content, true); + let (_, raw) = encode(case.content, false); + for (label, latm_first, latm_config) in [("ADTS before LATM", false, 12), ("LATM before ADTS", true, 7)] { + let (adts_pid, latm_pid) = if latm_first { (0x102, 0x101) } else { (0x101, 0x102) }; + let mut ts = Ts::default(); + let mut streams = [(0x0F, adts_pid), (0x11, latm_pid)]; + streams.sort_by_key(|s| s.1); + ts.psi(&streams); + for (i, (a, r)) in adts.iter().zip(&raw).enumerate() { + let pts = 1920 * i as i64; + let mut units = [(adts_pid, pes(pts, a)), (latm_pid, pes(pts, &loas(latm_config, r)))]; + units.sort_by_key(|u| u.0); + for (pid, unit) in units { + ts.pes(pid, &unit); + } + } + let p = write(&format!("latm-first-{latm_first}.ts"), &ts.out); + check(label, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// A LOAS frame of `payload` with its StreamMuxConfig: AAC LC, 48 kHz, channel configuration +/// `config`. +fn loas(config: u32, payload: &[u8]) -> Vec { + let mut bits = Bits::default(); + // useSameStreamMux, audioMuxVersion, allStreamsSameTimeFraming, numSubFrames, numProgram, numLayer + bits.put(0b001, 3); + bits.put(0, 13); + // AudioSpecificConfig and GASpecificConfig + bits.put(2, 5); + bits.put(3, 4); + bits.put(config, 4); + bits.put(0, 3); + // frameLengthType, latmBufferFullness, otherDataPresent, crcCheckPresent + bits.put(0, 3); + bits.put(0xFF, 8); + bits.put(0, 2); + let mut len = payload.len(); + while len >= 255 { + bits.put(255, 8); + len -= 255; + } + bits.put(len as u32, 8); + for &b in payload { + bits.put(u32::from(b), 8); + } + let body = bits.finish(); + let mut v = vec![0x56, 0xE0 | (body.len() >> 8) as u8, body.len() as u8]; + v.extend(body); + v +} + +/// A big-endian bit writer. +#[derive(Default)] +struct Bits { + out: Vec, + acc: u64, + n: u32, +} + +impl Bits { + fn put(&mut self, v: u32, n: u32) { + self.acc = self.acc << n | u64::from(v); + self.n += n; + while self.n >= 8 { + self.n -= 8; + self.out.push((self.acc >> self.n) as u8); + } + } + fn finish(mut self) -> Vec { + if self.n > 0 { + self.put(0, 8 - self.n); + } + self.out + } +} + +/// An audio PES packet (stream id 0xC0) presented at `pts` (90 kHz). +fn pes(pts: i64, payload: &[u8]) -> Vec { + let t = [0x21 | ((pts >> 29) & 0x0E) as u8, (pts >> 22) as u8, ((pts >> 14) & 0xFE) as u8 | 1, (pts >> 7) as u8, ((pts << 1) & 0xFE) as u8 | 1]; + let len = 8 + payload.len(); + let mut v = vec![0, 0, 1, 0xC0, (len >> 8) as u8, len as u8, 0x80, 0x80, 5]; + v.extend(t); + v.extend_from_slice(payload); + v +} + +/// A transport stream of one program (its PMT on PID 0x100), PES cut into 188-byte packets. +#[derive(Default)] +struct Ts { + out: Vec, + cc: std::collections::HashMap, +} + +impl Ts { + fn packet(&mut self, pid: u16, start: bool, payload: &[u8]) -> usize { + let cc = self.cc.entry(pid).or_insert(0); + let mut p = vec![0x47, u8::from(start) << 6 | (pid >> 8) as u8, pid as u8]; + let n = payload.len().min(184); + if n < 184 { + let stuffing = 183 - n; + p.extend([0x30 | *cc, stuffing as u8]); + if stuffing > 0 { + p.push(0); + p.extend(std::iter::repeat_n(0xFF, stuffing - 1)); + } + } else { + p.push(0x10 | *cc); + } + *cc = (*cc + 1) & 15; + p.extend_from_slice(&payload[..n]); + self.out.extend(p); + n + } + fn section(&mut self, pid: u16, body: &[u8]) { + let mut s = vec![0]; + s.extend_from_slice(body); + s.extend(crc32_mpeg(body).to_be_bytes()); + self.packet(pid, true, &s); + } + /// The PAT and a PMT of `streams` (stream type, PID). + fn psi(&mut self, streams: &[(u8, u16)]) { + self.section(0, &[0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00]); + let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x01, 0xF0, 0]; + for &(kind, pid) in streams { + pmt.extend([kind, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]); + } + pmt[2] = (pmt.len() + 1) as u8; + self.section(0x100, &pmt); + } + fn pes(&mut self, pid: u16, data: &[u8]) { + let (mut rest, mut start) = (data, true); + while !rest.is_empty() { + let n = self.packet(pid, start, rest); + rest = &rest[n..]; + start = false; + } + } +} + +/// CRC-32 of PSI sections (MSB first, polynomial 0x04C11DB7, initial value all ones). +fn crc32_mpeg(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for &b in data { + crc ^= u32::from(b) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { crc << 1 ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc +} diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index e0e7d2252..05205ab6a 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -94,8 +94,18 @@ fn cases() -> Vec { let (n71, g71) = seven_one(); let k5 = 1.0 / (1.0 + 2.0 * K); let kq = 1.0 / (1.0 + K); + let k61 = 1.0 / (1.5 + 2.0 * K); vec![ Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { + file: "dm61.m4a", + layout: "6.1", + names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"], + codec: &["-c:a", "aac", "-b:a", "448k"], + gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]], + tol: 0.15, + }, + Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 }, Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, @@ -154,24 +164,58 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { for c in cases() { let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue }; - let s = ours(&p); - let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) - for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { - for (side, gain) in g.iter().enumerate() { - let got = amp(&s, side, freqs[i]); - let want = sine * gain; - assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); - } - } - let theirs = ffmpeg_ac2(&p); - let (a, b) = (rms(&s), rms(&theirs)); - let db = 20.0 * (a / b).log10(); - assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); - let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); - assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); + check(&c, &p); } } +/// `p`, or a copy whose AAC sample entry names all `channels` of its PCE layout: ffmpeg 4.2 writes +/// 2 there for a PCE layout (8.1 the real count), and FilmCraft decodes as many channels as the +/// sample entry names, so the other five of a 6.1 would never reach the mix. +fn whole_pce_entry(p: &Path, channels: usize) -> PathBuf { + let mut bytes = std::fs::read(p).expect("read"); + let Some((asc, 2)) = aac_entry(&bytes) else { return p.to_path_buf() }; + if channels <= 2 || filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config).ok() != Some(0) { + return p.to_path_buf(); + } + // the `mp4a` entry's channelcount follows its 6 reserved bytes, data reference index and + // 8 reserved bytes + let at = bytes.windows(4).position(|w| w == b"mp4a").expect("an mp4a sample entry") + 4 + 6 + 2 + 8; + bytes[at..at + 2].copy_from_slice(&(channels as u16).to_be_bytes()); + eprintln!("{}: its sample entry names 2 of {channels} channels (ffmpeg 4.2): reading a copy naming all", p.display()); + let out = p.with_extension("entry.m4a"); + std::fs::write(&out, bytes).expect("write"); + out +} + +/// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track. +fn aac_entry(bytes: &[u8]) -> Option<(Vec, u32)> { + let file = filmcraft_isobmff::open(bytes).ok()?; + file.tracks.iter().flat_map(|t| &t.entries).find_map(|e| match &e.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)), + _ => None, + }) +} + +fn check(c: &Case, p: &Path) { + let p = &whole_pce_entry(p, c.names.len()); + let s = ours(p); + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) + for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { + for (side, gain) in g.iter().enumerate() { + let got = amp(&s, side, freqs[i]); + let want = sine * gain; + assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); + } + } + let theirs = ffmpeg_ac2(p); + let (a, b) = (rms(&s), rms(&theirs)); + let db = 20.0 * (a / b).log10(); + assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); + let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); + assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); +} + /// Full-scale in every channel must not clip. #[test] fn loud_surround_does_not_clip() { diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index a2849dc1d..0cae33417 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -24,9 +24,40 @@ fn have_ffmpeg() -> bool { *HAVE.get_or_init(|| Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success())) } +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none. Distro "free" +/// builds often omit `libx264` (the one named historically here) but ship an alternative, so pick +/// the first present by name rather than hard-coding one that may be absent. These tests only +/// need *an* H.264 encoder to make an oracle fixture; the encoder identity is irrelevant. +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + /// Run ffmpeg to produce `name` (once; written to a temp name and renamed so concurrent test /// processes never see a partial file). `None` when ffmpeg is unavailable. fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { + ffmpeg_fixture_inner(name, args, true) +} + +/// Like [`ffmpeg_fixture`] for a fixture this build of ffmpeg may fail to make: an encoder found +/// in the `-encoders` listing can still not run here (the hardware backends need their device, +/// Fedora's `libopenh264` is the `noopenh264` stub), and fixtures built from one, like +/// [`h264_mp4`], should skip with a note rather than fail on the packager's choice of ffmpeg — +/// the same way every test here skips when ffmpeg is missing altogether. +fn try_ffmpeg_fixture(name: &str, args: &[&str]) -> Option { + ffmpeg_fixture_inner(name, args, false) +} + +fn ffmpeg_fixture_inner(name: &str, args: &[&str], must_succeed: bool) -> Option { if !have_ffmpeg() { eprintln!("ffmpeg not found: skipping"); return None; @@ -39,7 +70,14 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { let ext = out.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], "")); let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg"); - assert!(st.success(), "ffmpeg failed for {name}"); + if !st.success() { + let _ = std::fs::remove_file(&tmp); + if must_succeed { + panic!("ffmpeg failed for {name}"); + } + eprintln!("this ffmpeg build cannot make {name}: skipping"); + return None; + } if std::fs::rename(&tmp, &out).is_err() { // Another test made it first; on Windows the rename fails while that test has it open. let _ = std::fs::remove_file(&tmp); @@ -48,39 +86,35 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { Some(out) } +/// Video options the chosen H.264 encoder actually accepts. `libx264` keeps its constant-quality +/// `-preset`/`-crf` pair; `libopenh264` rejects both (it takes a bitrate), and the hardware +/// backends take a bitrate too — without this, a build whose picked encoder is not libx264 failed +/// the fixture and silently skipped every check gated on it. +fn h264_video_options(enc: &str) -> Vec<&'static str> { + if enc == "libx264" { vec!["-preset", "medium", "-crf", "18"] } else { vec!["-b:v", "2M"] } +} + fn h264_mp4() -> Option { - ffmpeg_fixture( - "testsrc2_1080p.mp4", - &[ - "-f", - "lavfi", - "-i", - "testsrc2=size=1920x1080:rate=30:duration=2", - "-f", - "lavfi", - "-i", - "sine=frequency=440:sample_rate=48000:duration=2", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "-preset", - "medium", - "-crf", - "18", - "-colorspace", - "bt709", - "-color_primaries", - "bt709", - "-color_trc", - "bt709", - "-c:a", - "aac", - "-ac", - "2", - "-shortest", - ], - ) + // Made with whatever encoder this build has listed: a listed encoder can still fail at run + // time (the hardware ones without their device), so this may skip rather than fail. + let enc = h264_encoder()?; + let mut args: Vec<&'static str> = vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=1920x1080:rate=30:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + enc, + "-pix_fmt", + "yuv420p", + ]; + args.extend(h264_video_options(enc)); + args.extend(["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-c:a", "aac", "-ac", "2", "-shortest"]); + try_ffmpeg_fixture("testsrc2_1080p.mp4", &args) } fn prores_mov() -> Option { @@ -172,9 +206,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -281,7 +320,10 @@ fn h264_audio_samples() { /// H.264 + AAC at 44.1 kHz in MP4 (ffmpeg writes an edit list that skips the AAC priming): /// 440 Hz left, a 200 → 1000 Hz sweep right, 29.97 fps. fn aac_44k_mp4() -> Option { - ffmpeg_fixture( + // Made with whatever encoder this build has listed: a listed encoder can still fail at run + // time (the hardware ones without their device), so this may skip rather than fail. + let enc = h264_encoder()?; + try_ffmpeg_fixture( "aac_44k.mp4", &[ "-f", @@ -293,7 +335,7 @@ fn aac_44k_mp4() -> Option { "-i", "aevalsrc=0.5*sin(2*PI*440*t)|0.5*sin(2*PI*(200+400*t/3)*t):s=44100:d=3", "-c:v", - "libx264", + enc, "-pix_fmt", "yuv420p", "-c:a", @@ -476,12 +518,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case. #[test] -fn mxf_is_importable() { - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); - assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -527,6 +569,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame_in(&p, n, "bt601")); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; @@ -878,7 +1038,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel). - let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); + let gains = effectcraft_media::downmix::stereo_gains(channels as usize); let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); let rms = (sum / n.max(1) as f64).sqrt(); @@ -1098,7 +1258,7 @@ fn a_called_off_read_does_not_decode() { /// footage reports it (not a black frame without a word). #[test] fn an_unsupported_stream_reports_why_it_shows_no_picture() { - let Some(p) = ffmpeg_fixture( + let Some(p) = try_ffmpeg_fixture( "h264_high422_10bit.mp4", &["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"], ) else { @@ -1122,3 +1282,13 @@ fn an_unsupported_stream_reports_why_it_shows_no_picture() { pool.forget(&f.path); assert_eq!(pool.problem(&f), None); } + +#[test] +fn the_h264_fixture_options_fit_the_picked_encoder() { + assert_eq!(h264_video_options("libx264"), ["-preset", "medium", "-crf", "18"]); + for enc in ["libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv"] { + let options = h264_video_options(enc); + assert!(!options.iter().any(|o| *o == "-preset" || *o == "-crf"), "{enc} rejects preset/crf: {options:?}"); + assert!(options.contains(&"-b:v"), "{enc} gets a bitrate: {options:?}"); + } +} diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 5f7fb8037..69bed4a9f 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). +mod aac_layout; mod downmix; mod exif_orientation; mod footage; diff --git a/crates/media/tests/it/open_in_place.rs b/crates/media/tests/it/open_in_place.rs index 78b0abcbc..91eab2ba3 100644 --- a/crates/media/tests/it/open_in_place.rs +++ b/crates/media/tests/it/open_in_place.rs @@ -19,8 +19,26 @@ fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") } +/// An H.264 encoder this ffmpeg build actually ships, or `None` when it has none (see the same +/// helper in `footage.rs`: distro "free" builds often omit `libx264` and ship an alternative). +fn h264_encoder() -> Option<&'static str> { + static ENC: std::sync::OnceLock> = std::sync::OnceLock::new(); + *ENC.get_or_init(|| { + let out = Command::new("ffmpeg").args(["-hide_banner", "-encoders"]).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + ["libx264", "libopenh264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_v4l2m2m", "h264_amf"] + .into_iter() + .find(|name| text.lines().any(|l| l.split_whitespace().any(|tok| tok == *name))) + }) +} + /// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test -/// processes never see a partial file). `None` without ffmpeg. +/// processes never see a partial file). `None` without ffmpeg, or when this build cannot encode +/// H.264 at all — the test only measures how a movie is read, so it skips rather than asserting +/// on the packager's choice of ffmpeg. fn small_mp4() -> Option { let out = fixtures().join("in_place_small.mp4"); if out.exists() { @@ -29,16 +47,21 @@ fn small_mp4() -> Option { if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { return None; } + let enc = h264_encoder()?; std::fs::create_dir_all(fixtures()).unwrap(); let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id())); let st = Command::new("ffmpeg") .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"]) .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"]) - .args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .args(["-c:v", enc, "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) .arg(&tmp) .status() .unwrap(); - assert!(st.success(), "ffmpeg failed"); + if !st.success() { + let _ = std::fs::remove_file(&tmp); + eprintln!("this ffmpeg build cannot make in_place_small.mp4: skipping"); + return None; + } std::fs::rename(&tmp, &out).unwrap(); Some(out) } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 33ee26726..ebe7da7e8 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -14,5 +14,6 @@ effectcraft-geom = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +log = { workspace = true } image = { workspace = true } miniz_oxide = "0.9" diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index e17d014f6..c0b736359 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,7 +11,7 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. -use std::sync::Arc; +use std::{cell::Cell, sync::Arc}; use serde_json::Value; @@ -87,6 +87,8 @@ pub fn uri_decode(s: &str) -> String { while i < b.len() { if b[i] == b'%' && i + 2 < b.len() + && b[i + 1].is_ascii_hexdigit() + && b[i + 2].is_ascii_hexdigit() && let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) { out.push(v); @@ -112,6 +114,14 @@ fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option>) -> Result { j: &'a Value, buffers: Vec>, + /// Zero components that buffer-less accessors may still allocate, across the whole file: + /// nothing backs them, so many references to one capped accessor must not multiply (#2139). + zero_left: Cell, + /// Components every accessor read may still allocate, across the whole file: each primitive + /// that references an accessor keeps its own copy, so references must not multiply memory. + read_left: Cell, + /// The budget `read_left` started from, for the error message. + read_cap: usize, } fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { @@ -126,10 +136,18 @@ fn indices(v: &Value, k: &str) -> Vec { 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). +/// The most components accessors without a buffer view (all zeros) may have, together: nothing +/// in the file backs their counts, so they are capped instead of checked against a buffer +/// (#1235, #2139). const MAX_ZERO_COMPONENTS: usize = 1 << 24; +/// The most components all accessor reads in one file may allocate together (about 1 GiB as +/// `f32`/`u32`). Each primitive keeps its own copy of what it reads, so an accessor referenced +/// N times costs N times; this bounds that by a fixed amount rather than by buffer size, because +/// real assets legitimately share one vertex buffer across many primitives. A 10M-triangle scan +/// (5M vertices with position, normal, UV and tangent, 30M indices) reads about 90M. +const MAX_READ_COMPONENTS: usize = 1 << 28; + /// 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> { @@ -150,7 +168,30 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { out } +/// Bytes of component `c` of element `i`, with checked offsets (the span was validated, but +/// nothing derived from the file is indexed unchecked). +fn component(buf: &[u8], base: usize, stride: usize, i: usize, c: usize, csize: usize) -> Result<&[u8]> { + let o = i.checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(c.checked_mul(csize)?)); + o.and_then(|o| buf.get(o..o.checked_add(csize)?)).ok_or_else(|| perr("accessor reads past its buffer")) +} + impl Doc<'_> { + /// Charge `total` components to the file's read budget before allocating them. + fn charge(&self, total: usize) -> Result<()> { + let left = self.read_left.get(); + let Some(rest) = left.checked_sub(total) else { + let cap = self.read_cap; + return Err(perr(format!("accessor reads need more than {cap} components in total ({total} requested, {left} left)"))); + }; + self.read_left.set(rest); + Ok(()) + } + + /// Charge `count` items of `per` components each. + fn charge_each(&self, count: usize, per: usize) -> Result<()> { + self.charge(count.checked_mul(per).ok_or_else(|| perr("primitive is too large"))?) + } + /// 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. @@ -161,16 +202,19 @@ impl Doc<'_> { }; 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})"))); - } + let left = self.zero_left.get(); + let Some(rest) = left.checked_sub(total) else { + return Err(perr(format!("accessors without a bufferView have more than {MAX_ZERO_COMPONENTS} components ({total} requested, {left} left)"))); + }; + self.zero_left.set(rest); + self.charge(total)?; 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 elem = csize.checked_mul(n).ok_or_else(past)?; 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"))); @@ -181,6 +225,7 @@ impl Doc<'_> { return Err(past()); } } + self.charge(total)?; Ok((count, total, Some(Span { buf, base, stride }))) } @@ -214,8 +259,7 @@ impl Doc<'_> { // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out[i * n + c] = match ct { 5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]), 5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32, @@ -264,8 +308,7 @@ impl Doc<'_> { let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out.push(match csize { 1 => s[0] as u32, 2 => u16::from_le_bytes([s[0], s[1]]) as u32, @@ -279,6 +322,11 @@ impl Doc<'_> { /// Parse a `.gltf` (JSON) or `.glb` file. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { + parse_with_budget(bytes, resolve, MAX_READ_COMPONENTS) +} + +/// [`parse`] with a read budget of `read_budget` accessor components for the whole file. +pub(crate) fn parse_with_budget(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, read_budget: usize) -> Result { let (json, bin) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) }; let j: Value = serde_json::from_slice(json).map_err(|e| perr(format!("glTF JSON: {e}")))?; let ver = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or(""); @@ -300,7 +348,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< }; buffers.push(data); } - let d = Doc { j: &j, buffers }; + let d = Doc { j: &j, buffers, zero_left: Cell::new(MAX_ZERO_COMPONENTS), read_left: Cell::new(read_budget), read_cap: read_budget }; // Images → textures (decoded once per image; samplers set the wrap modes). let mut images: Vec>> = vec![]; @@ -567,7 +615,11 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result = match idx(p, "indices") { Some(a) => d.ints(a)?, - None => (0..n as u32).collect(), + None => { + // Generated indices are allocated like a read, once per referencing primitive. + d.charge(n)?; + (0..n as u32).collect() + } }; if raw.iter().any(|&i| i as usize >= n) { return Err(perr("index out of range")); @@ -587,11 +639,19 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result indices.extend(raw.as_chunks::<3>().0.iter().flatten()), } + // The triangle list is kept besides the raw indices (a strip or fan of k indices becomes + // 3(k-2)), so it is charged like a read. + d.charge(indices.len())?; if normals.len() != n { normals.clear(); } let material = idx(p, "material").filter(|&m| m < materials.len()); let skinned = joints.len() == n && weights.len() == n; + if normals.is_empty() { + // Flat normals unshare every vertex: position and normal, plus UVs and joints/weights. + let per = 6 + if uvs.len() == n { 2 } else { 0 } + if skinned { 8 } else { 0 }; + d.charge_each(indices.len(), per)?; + } let mut prim = Primitive { positions, normals, @@ -608,7 +668,20 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result Option<&str> { + let file = name.rsplit(['/', '\\']).next()?; + (!file.is_empty() && file.len() < name.len()).then_some(file) +} + +/// A material reference resolved against the model's folder: the name as written (a name +/// containing spaces is one name), then its file name (an absolute path from another machine, +/// #1274), then the final token (the options convention, `-bm 1 file.png`). +fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> Option> { + let mut tried: Vec<&str> = vec![]; + for candidate in [name, sibling_name(name).unwrap_or(name), name.split_whitespace().last().unwrap_or(name)] { + if tried.contains(&candidate) { + continue; + } + tried.push(candidate); + if let Some(b) = resolve(candidate) { + return Some(b); + } + } + None +} + /// Parse MTL text into named materials (textures appended to `textures`). pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { let mut out: Vec = vec![]; let mut loaded: HashMap> = HashMap::new(); let mut tex = |name: &str, textures: &mut Vec>| -> Option { - // Options (`-bm 1 file.png`): the file name is the last token. - let file = name.split_whitespace().last()?.to_string(); - let t = *loaded.entry(file.clone()).or_insert_with(|| { - let b = resolve(&file)?; - let t = Texture::decode(&b).ok()?; - textures.push(Arc::new(t)); - Some(textures.len() - 1) + let key = name.trim().to_string(); + let t = *loaded.entry(key.clone()).or_insert_with(|| { + let Some(b) = resolve_reference(resolve, &key) else { + log::warn!("model: texture `{key}` was not found next to the model"); + return None; + }; + match Texture::decode(&b) { + Ok(t) => { + textures.push(Arc::new(t)); + Some(textures.len() - 1) + } + Err(e) => { + log::warn!("model: cannot decode texture `{key}`: {e}"); + None + } + } }); t.map(|texture| TexRef { texture, uv_set: 0 }) }; @@ -147,13 +181,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); - for file in std::iter::once(name).chain(name.split_whitespace()) { + let mut found = false; + for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { if let Some(b) = resolve(file) { let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); materials.append(&mut ms); + found = true; break; } } + if !found && !name.is_empty() { + log::warn!("model: material library `{name}` was not found next to the model"); + } } "usemtl" => { let name = line["usemtl".len()..].trim(); diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 7c98c1ae7..5ee6a6950 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -119,6 +119,92 @@ fn obj_without_normals_gets_them() { assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err()); } +#[test] +fn obj_textures_from_another_machine_find_the_file_next_to_the_model() { + // No committed media: the material file and its textures are written by the test itself. + let d = std::env::temp_dir().join(format!("ec-obj-sibling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let png = { + let img = image::RgbaImage::from_fn(2, 2, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + std::fs::write(d.join("checker.png"), &png).unwrap(); + std::fs::write(d.join("Diffuse Map.png"), &png).unwrap(); + let mtl = "newmtl Mat\nKd 0.640000 0.640000 0.640000\nmap_Kd C:\\Users\\somebody\\Desktop\\textures\\checker.png\n\ + newmtl Spaced\nKd 1.000000 1.000000 1.000000\nmap_Kd Diffuse Map.png\n\ + newmtl Missing\nKd 0.500000 0.500000 0.500000\nmap_Kd nowhere.png\n"; + let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\ + usemtl Mat\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n"; + // Like the media layer's resolver: a relative name joins the model's folder; an absolute + // path is only looked up where it points (here: nowhere else). + let resolve = |uri: &str| -> Option> { + let p = std::path::Path::new(uri); + if p.is_absolute() { + return None; + } + std::fs::read(d.join(p)).ok() + }; + let resolver = |uri: &str| -> Option> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } }; + let m = obj::parse(obj.as_bytes(), &resolver).unwrap(); + assert_eq!(m.materials.len(), 3); + let mat = &m.materials[0]; + let t = mat.base_color_tex.expect("the absolute map_Kd path finds checker.png next to the .obj"); + let tex = &m.textures[t.texture]; + assert_eq!((tex.width, tex.height), (2, 2)); + assert_eq!(tex.data[0], [1.0, 0.0, 0.0, 1.0]); + assert!((mat.base_color[0] - obj::srgb_to_linear(0.64)).abs() < 1e-6, "Kd still tints the texture"); + assert!(m.materials[1].base_color_tex.is_some(), "a texture name containing spaces is one name"); + assert!(m.materials[2].base_color_tex.is_none(), "a genuinely missing texture keeps the Kd colour"); + assert!((m.materials[2].base_color[0] - obj::srgb_to_linear(0.5)).abs() < 1e-6); + // The same fallback applies to the material library itself. + let here = obj.replace("mtllib quad.mtl", "mtllib C:\\build\\quad.mtl"); + assert_eq!(obj::parse(here.as_bytes(), &resolver).unwrap().materials.len(), 3); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn obj_texture_prefers_the_complete_name_over_its_final_token() { + // Two textures sit beside the model: `Map.png` (the final whitespace token of the reference) + // and `Diffuse Map.png` (the complete name). The complete name must win, its basename next, + // the final token last (the `-bm 1 file.png` options convention). + let d = std::env::temp_dir().join(format!("ec-obj-full-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let png = |w: u32| { + let img = image::RgbaImage::from_fn(w, 2, |_, _| image::Rgba([255, 0, 0, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + std::fs::write(d.join("Map.png"), png(2)).unwrap(); + std::fs::write(d.join("Diffuse Map.png"), png(4)).unwrap(); + let mtl = "newmtl Spaced\nmap_Kd Diffuse Map.png\n\ + newmtl Absolute\nmap_Kd C:\\tex\\Diffuse Map.png\n\ + newmtl Options\nmap_Kd -bm 1 Map.png\n"; + let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\ + usemtl Spaced\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n"; + // Like the media layer's resolver: a relative name joins the model's folder; an absolute + // path is only looked up where it points (here: nowhere else). + let resolve = |uri: &str| -> Option> { + let p = std::path::Path::new(uri); + if p.is_absolute() { + return None; + } + std::fs::read(d.join(p)).ok() + }; + let resolver = |uri: &str| -> Option> { if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } }; + let m = obj::parse(obj.as_bytes(), &resolver).unwrap(); + assert_eq!(m.materials.len(), 3); + let size = |mat: &Material| mat.base_color_tex.map(|t| (m.textures[t.texture].width, m.textures[t.texture].height)); + assert_eq!(size(&m.materials[0]), Some((4, 2)), "the complete `Diffuse Map.png` wins over its final token `Map.png`"); + assert_eq!(size(&m.materials[1]), Some((4, 2)), "an absolute path's spaced basename beats its final token"); + assert_eq!(size(&m.materials[2]), Some((2, 2)), "the `-bm 1 file.png` options convention still finds the final token"); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn channel_interpolation_modes() { let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; @@ -564,3 +650,84 @@ fn gltf_strided_and_buffer_less_accessors_still_import() { 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()); } + +#[test] +fn gltf_issue_2139_huge_mat4_count_is_an_error() { + // The audit's trigger (#2139): a MAT4 accessor with a count of 10^11, with and without a + // buffer view, must fail cleanly instead of sizing an allocation from the count. + let view = r#"{"buffer": 0, "byteLength": 64}"#; + for acc in [ + r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + r#"{"componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + ] { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 1}"#, view, 64) + .replace(r#""count": 1}]"#, &format!(r#""count": 1}}, {acc}]"#)) + .replace(r#""nodes": [{"mesh": 0}]"#, r#""nodes": [{"mesh": 0, "skin": 0}], "skins": [{"joints": [0], "inverseBindMatrices": 1}]"#); + assert!(doc.contains("inverseBindMatrices"), "{doc}"); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{acc}"); + } +} + +#[test] +fn gltf_buffer_less_accessors_share_one_zero_budget() { + // Each buffer-less accessor is capped, but nothing backs any of them, so referencing a capped + // one from many primitives must not multiply the allocation (#2139). + let acc = r#"{"componentType": 5126, "type": "VEC3", "count": 5000000}"#; + let prims = [r#"{"attributes": {"POSITION": 0}}"#; 4].join(", "); + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")); + assert_eq!(doc.matches(r#""POSITION": 0"#).count(), 4); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("without a bufferView"), "{e}"); + // One such accessor read once still imports. + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_ok()); +} + +/// `refs` primitives sharing one buffer-backed `verts`-vertex POSITION accessor, without indices. +fn gltf_shared_positions(verts: usize, refs: usize) -> String { + let acc = format!(r#"{{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": {verts}}}"#); + let prims = vec![r#"{"attributes": {"POSITION": 0}}"#; refs].join(", "); + gltf_doc(&acc, r#"{"buffer": 0}"#, verts * 12).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")) +} + +#[test] +fn gltf_buffer_backed_reads_share_one_read_budget() { + // A buffer-backed accessor is checked against its buffer, but each primitive that references + // it reads and keeps its own copy, so a small file listing many references must not ask for + // memory set by the reference count (#2139 review). Everything a primitive keeps is charged + // to one per-file budget: 1,000 vertices without indices or normals cost 3,000 position + // components, 1,000 generated indices, a 999-index triangle list and 999 flat-shaded + // vertices of 6 components, 10,993 in all. + let one = gltf_shared_positions(1000, 1); + let m = crate::gltf::parse_with_budget(one.as_bytes(), &|_| None, 20_000).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 1); + let three = gltf_shared_positions(1000, 3); + assert_eq!(three.matches(r#""POSITION": 0"#).count(), 3); + let e = crate::gltf::parse_with_budget(three.as_bytes(), &|_| None, 20_000).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("more than 20000 components"), "{e}"); + // The real cap leaves room for legitimate sharing: many primitives over one vertex buffer. + let m = crate::gltf::parse(gltf_shared_positions(1000, 50).as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 50); +} + +#[test] +fn gltf_missing_indices_are_charged_to_the_read_budget() { + // Without `indices` every vertex gets a generated index, allocated like a read: after the + // 3,000 position components, 999 left is one short for the 1,000 indices. + let doc = gltf_shared_positions(1000, 1); + let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 3999).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("(1000 requested, 999 left)"), "{e}"); +} + +#[test] +fn gltf_strip_expansion_is_charged_to_the_read_budget() { + // A strip of k indices becomes 3(k-2) triangle indices and as many flat-shaded vertices, so + // the output is charged, not only the 1,009 components read. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0}"#, 1036) + .replace(r#""count": 3}]"#, r#""count": 3}, {"bufferView": 0, "componentType": 5121, "type": "SCALAR", "count": 1000}]"#) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1, "mode": 5"#); + let m = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 30_000).unwrap(); + assert_eq!(m.meshes[0].primitives[0].indices.len(), 2994); + let e = crate::gltf::parse_with_budget(doc.as_bytes(), &|_| None, 5000).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("more than 5000 components"), "{e}"); +} diff --git a/crates/opusenc/src/bands.rs b/crates/opusenc/src/bands.rs index cf3c590e8..f90a554c4 100644 --- a/crates/opusenc/src/bands.rs +++ b/crates/opusenc/src/bands.rs @@ -97,12 +97,24 @@ pub fn exp_rotation_inv(x: &mut [f32], len: usize, k: usize, spread: usize) { } } +/// Relative slack for the pulse-candidate filter in [`pvq_search`]: far above the f32 rounding of +/// the filter score (a few 1e-7), far below the spread of real scores. +const PVQ_SCORE_SLACK: f32 = 1e-5; + /// Greedy PVQ search: the integer vector `y` with `sum |y| = k` closest in direction to `x`. pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { let n = x.len(); y[..n].fill(0); - let abs: Vec = x.iter().map(|v| v.abs()).collect(); - let sum: f32 = abs.iter().sum(); + // `abs`, `dy` (2 y + 1, infinite in the padding) and the per-pass scores `score`, each padded to + // whole 8-lane chunks. + let np = n.next_multiple_of(8); + let mut buf = vec![0f32; 3 * np]; + let (abs, rest) = buf.split_at_mut(np); + let (dy, score) = rest.split_at_mut(np); + for (a, v) in abs.iter_mut().zip(x) { + *a = v.abs(); + } + let sum: f32 = abs[..n].iter().sum(); if !sum.is_finite() || sum <= 1e-15 { y[0] = k as i32; return; @@ -114,30 +126,69 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { // Pre-project onto the pyramid (rounding down) and place the rest greedily. let r = (k as f32 - 1.0) / sum; for j in 0..n { - let p = (abs[j] * r).floor() as i32; + // Non-negative and at most k, so the truncating cast is the floor. + let p = (abs[j] * r) as i32; y[j] = p; placed += p as usize; xy += abs[j] * p as f32; yy += (p * p) as f32; } } + for j in 0..np { + dy[j] = if j < n { 2.0 * y[j] as f32 + 1.0 } else { f32::INFINITY }; + } while placed < k { + // Vectorisable scores (xy + |x_j|)^2 / (yy + 2 y_j + 1) and their maximum. + for ((sc, &a), &d) in score.iter_mut().zip(abs.iter()).zip(dy.iter()) { + let t = xy + a; + *sc = t * t / (yy + d); + } + let mut lane_max = [0f32; 8]; + for c in score.as_chunks::<8>().0 { + for i in 0..8 { + lane_max[i] = if c[i] > lane_max[i] { c[i] } else { lane_max[i] }; + } + } + let top = lane_max.iter().fold(0f32, |m, &v| if v > m { v } else { m }); + // The exact cross-multiplied test below, in index order with the first maximum winning, only + // matters for entries within rounding distance of the top score: every other entry is beaten + // by the top one whenever it is reached and never beats it afterwards. Running the test on + // those alone therefore picks the same pulse as running it on all of them. + let filter = top > 1e-30 && top.is_finite(); + let lo = top * (1.0 - PVQ_SCORE_SLACK); + // Usually exactly one entry qualifies; its index falls out of a lane-wise count. + let mut count = [0u32; 8]; + let mut index = [0u32; 8]; + for (ci, c) in score.as_chunks::<8>().0.iter().enumerate() { + for i in 0..8 { + let hit = u32::from(c[i] >= lo); + count[i] += hit; + index[i] += hit * (ci * 8 + i) as u32; + } + } let mut best = 0usize; - let mut best_num = -1f32; - let mut best_den = 1f32; - for j in 0..n { - let num = xy + abs[j]; - let num = num * num; - let den = yy + 2.0 * y[j] as f32 + 1.0; - if num * best_den > best_num * den { - best_num = num; - best_den = den; - best = j; + if filter && count.iter().sum::() == 1 { + best = index.iter().sum::() as usize; + } else { + let mut best_num = -1f32; + let mut best_den = 1f32; + for j in 0..n { + if !filter || score[j] >= lo { + let num = xy + abs[j]; + let num = num * num; + let den = yy + 2.0 * y[j] as f32 + 1.0; + if num * best_den > best_num * den { + best_num = num; + best_den = den; + best = j; + } + } } } xy += abs[best]; yy += 2.0 * y[best] as f32 + 1.0; y[best] += 1; + dy[best] = 2.0 * y[best] as f32 + 1.0; placed += 1; } for j in 0..n { @@ -147,25 +198,45 @@ pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) { } } -/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps -/// back to `y`. -pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec) { - let n = y.len(); - let w = k + 1; - v.clear(); - v.resize((n + 1) * w, 0); +/// Largest band width and pulse count the encoder codes (176 coefficients, `get_pulses(40)` = 128). +const PVQ_MAX_N: usize = 176; +const PVQ_MAX_K: usize = 128; + +/// Row-major `V(n, k)` for `n <= max_n`, `k <= max_k` with row stride `max_k + 1`, saturated at +/// `u32::MAX` (a codebook that large is never coded, see `rate::fits_in32`). +fn build_pvq_table(max_n: usize, max_k: usize) -> Vec { + let w = max_k + 1; + let mut v = vec![0u32; (max_n + 1) * w]; v[0] = 1; - for nn in 1..=n { + for nn in 1..=max_n { v[nn * w] = 1; - for kk in 1..=k { - let a = v[(nn - 1) * w + kk]; - let b = v[nn * w + kk - 1]; - let c = v[(nn - 1) * w + kk - 1]; - v[nn * w + kk] = a.saturating_add(b).saturating_add(c); + for kk in 1..=max_k { + let sum = v[(nn - 1) * w + kk] as u64 + v[nn * w + kk - 1] as u64 + v[(nn - 1) * w + kk - 1] as u64; + v[nn * w + kk] = sum.min(u32::MAX as u64) as u32; } } - let total = v[n * w + k]; - let vv = |nn: usize, kx: usize| v[nn * w + kx]; + v +} + +fn pvq_table() -> &'static [u32] { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| build_pvq_table(PVQ_MAX_N, PVQ_MAX_K)) +} + +/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps +/// back to `y`. +pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder) { + let n = y.len(); + // Shapes outside the coded range (never produced by the band layout) get a local table. + let local; + let (v, w) = if n <= PVQ_MAX_N && k <= PVQ_MAX_K { + (pvq_table(), PVQ_MAX_K + 1) + } else { + local = build_pvq_table(n, k); + (&local[..], k + 1) + }; + let row = |nn: usize| &v[nn * w..(nn + 1) * w]; + let total = row(n)[k] as u64; let mut index = 0u64; let mut kk = k; for j in 0..n { @@ -174,10 +245,11 @@ pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec { pub i: usize, pub spread: usize, pub remaining_bits: i32, - pvq_v: Vec, iy: Vec, tmp: Vec, } @@ -288,7 +359,7 @@ impl BandEnc<'_, '_> { iy.clear(); iy.resize(n, 0); pvq_search(&tmp, k, &mut iy); - encode_pulses(&iy, k, self.enc, &mut self.pvq_v); + encode_pulses(&iy, k, self.enc); self.iy = iy; self.tmp = tmp; } @@ -310,7 +381,7 @@ pub fn quant_all_bands(m: &Mode, frame: CeltFrame, x: &[f32], n_total: usize, al let BandCoding { spread, total_bits } = coding; let mm = 1usize << lm; debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo"); - let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() }; + let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, iy: Vec::new(), tmp: Vec::new() }; for i in start..end { ctx.i = i; let xo = mm * EBANDS[i] as usize; @@ -460,12 +531,11 @@ mod tests { #[test] fn pvq_index_roundtrip_exhaustive() { - let mut v = Vec::new(); for n in 1..6usize { for k in 1..6usize { for y in all_codewords(n, k) { let mut e = RangeEncoder::new(); - encode_pulses(&y, k, &mut e, &mut v); + encode_pulses(&y, k, &mut e); let buf = e.done(16); let mut d = RangeDecoder::new(&buf); assert_eq!(decode_pulses(n, k, &mut d), y, "n={n} k={k}"); @@ -474,6 +544,165 @@ mod tests { } } + /// The per-call table encoder this module used before the shared table. + fn encode_pulses_reference(y: &[i32], k: usize, enc: &mut RangeEncoder) { + let n = y.len(); + let w = k + 1; + let mut v = vec![0u64; (n + 1) * w]; + v[0] = 1; + for nn in 1..=n { + v[nn * w] = 1; + for kk in 1..=k { + let a = v[(nn - 1) * w + kk]; + let b = v[nn * w + kk - 1]; + let c = v[(nn - 1) * w + kk - 1]; + v[nn * w + kk] = a.saturating_add(b).saturating_add(c); + } + } + let total = v[n * w + k]; + let vv = |nn: usize, kx: usize| v[nn * w + kx]; + let mut index = 0u64; + let mut kk = k; + for j in 0..n { + if kk == 0 { + break; + } + let rem = n - j; + let t = y[j].unsigned_abs() as usize; + let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum(); + if y[j] < 0 { + base += (vv(rem - 1, kk) + vv(rem, kk)) / 2; + } + index += base; + kk -= t; + } + if total >= 2 { + enc.uint(index as u32, total as u32); + } + } + + #[test] + fn pvq_table_encode_matches_per_call_table() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move |m: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % m + }; + let mut checked = 0; + for n in (1..=PVQ_MAX_N).step_by(5).chain([PVQ_MAX_N]) { + for k in (1..=PVQ_MAX_K).step_by(3).chain([PVQ_MAX_K]) { + if crate::rate::pvq_v(n, k) > u32::MAX as u128 { + continue; + } + for _ in 0..4 { + let mut y = vec![0i32; n]; + for _ in 0..k { + let at = next(n as u64) as usize; + y[at] += if next(2) == 0 { 1 } else { -1 }; + } + // Opposite pulses cancel; top up so that sum |y| = k again. + let mut have: usize = y.iter().map(|v| v.unsigned_abs() as usize).sum(); + while have < k { + let at = next(n as u64) as usize; + y[at] += if y[at] < 0 { -1 } else { 1 }; + have += 1; + } + let (mut a, mut b) = (RangeEncoder::new(), RangeEncoder::new()); + encode_pulses(&y, k, &mut a); + encode_pulses_reference(&y, k, &mut b); + assert_eq!(a.done(16), b.done(16), "n={n} k={k}"); + checked += 1; + } + } + } + assert!(checked > 100, "{checked}"); + } + + /// The scalar greedy search the vectorised one replaced. + fn pvq_search_ref(x: &[f32], k: usize, y: &mut [i32]) { + let n = x.len(); + y[..n].fill(0); + let abs: Vec = x.iter().map(|v| v.abs()).collect(); + let sum: f32 = abs.iter().sum(); + if !sum.is_finite() || sum <= 1e-15 { + y[0] = k as i32; + return; + } + let mut placed = 0usize; + let mut xy = 0f32; + let mut yy = 0f32; + if k > n / 2 { + let r = (k as f32 - 1.0) / sum; + for j in 0..n { + let p = (abs[j] * r).floor() as i32; + y[j] = p; + placed += p as usize; + xy += abs[j] * p as f32; + yy += (p * p) as f32; + } + } + while placed < k { + let mut best = 0usize; + let mut best_num = -1f32; + let mut best_den = 1f32; + for j in 0..n { + let num = xy + abs[j]; + let num = num * num; + let den = yy + 2.0 * y[j] as f32 + 1.0; + if num * best_den > best_num * den { + best_num = num; + best_den = den; + best = j; + } + } + xy += abs[best]; + yy += 2.0 * y[best] as f32 + 1.0; + y[best] += 1; + placed += 1; + } + for j in 0..n { + if x[j] < 0.0 { + y[j] = -y[j]; + } + } + } + + #[test] + fn pvq_search_matches_scalar_reference() { + let mut state = 0x9e3779b97f4a7c15u64; + let mut rnd = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0 + }; + let mut a = vec![0i32; 200]; + let mut b = vec![0i32; 200]; + let mut cases = 0; + for round in 0..6000 { + let n = 1 + (round * 37 + (rnd().abs() * 176.0) as usize) % 176; + let k = 1 + (rnd().abs() * 127.0) as usize; + let scale = [1.0f32, 1e-9, 1e6, 3e-3][round % 4]; + let x: Vec = (0..n) + .map(|_| match round % 6 { + 0 => rnd(), + 1 => if rnd() > 0.8 { rnd() } else { 0.0 }, + 2 => (rnd() * 4.0).round() / 4.0, + 3 => if rnd() > 0.0 { 1.0 } else { -1.0 }, + 4 => rnd().powi(5), + _ => if rnd() > 0.95 { 3.0 * rnd() } else { 1e-4 * rnd() }, + } * scale) + .collect(); + pvq_search(&x, k, &mut a); + pvq_search_ref(&x, k, &mut b); + assert_eq!(a[..n], b[..n], "n={n} k={k} round={round}"); + cases += 1; + } + assert_eq!(cases, 6000); + } + #[test] fn pvq_search_finds_good_direction() { let x: Vec = (0..32).map(|i| ((i as f32) * 0.7).sin()).collect(); diff --git a/crates/opusenc/src/mdct.rs b/crates/opusenc/src/mdct.rs index 814de0375..131c4a7a1 100644 --- a/crates/opusenc/src/mdct.rs +++ b/crates/opusenc/src/mdct.rs @@ -24,6 +24,10 @@ impl Cpx { fn add(self, o: Cpx) -> Cpx { Cpx { re: self.re + o.re, im: self.im + o.im } } + #[inline] + fn sub(self, o: Cpx) -> Cpx { + Cpx { re: self.re - o.re, im: self.im - o.im } + } } /// Mixed-radix (2, 3, 4, 5) forward complex FFT, unscaled (decimation in time, recursive). @@ -71,18 +75,64 @@ impl Fft { self.rec(&mut out[q * m..(q + 1) * m], &inp[q * in_stride..], in_stride * p, m, fi + 1, tw_stride * p); } } - let big = self.n; - let mut t = [Cpx::default(); 5]; - for k in 0..m { - for (q, tq) in t.iter_mut().enumerate().take(p) { - *tq = out[q * m + k].mul(self.twiddle[(q * k * tw_stride) % big]); + // The twiddle index q * k * tw_stride is always below n, so no modulo is needed. + let tw = &self.twiddle; + match p { + 2 => { + for k in 0..m { + let a0 = out[k]; + let a1 = out[m + k].mul(tw[k * tw_stride]); + out[k] = a0.add(a1); + out[m + k] = a0.sub(a1); + } } - for q2 in 0..p { - let mut acc = Cpx::default(); - for (q, tq) in t.iter().enumerate().take(p) { - acc = acc.add(tq.mul(self.twiddle[((q * q2) % p) * (big / p)])); + 4 => { + for k in 0..m { + let t0 = out[k]; + let t1 = out[m + k].mul(tw[k * tw_stride]); + let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]); + let t3 = out[3 * m + k].mul(tw[3 * k * tw_stride]); + let (s0, d0) = (t0.add(t2), t0.sub(t2)); + let (s1, d1) = (t1.add(t3), t1.sub(t3)); + out[k] = s0.add(s1); + out[2 * m + k] = s0.sub(s1); + // The odd outputs combine d0 with -j * d1 = (d1.im, -d1.re) and its negative. + out[m + k] = Cpx { re: d0.re + d1.im, im: d0.im - d1.re }; + out[3 * m + k] = Cpx { re: d0.re - d1.im, im: d0.im + d1.re }; + } + } + 3 => { + // Imaginary part of exp(-2 pi i / 3) = -sqrt(3) / 2. + let h = tw[self.n / 3].im; + for k in 0..m { + let t0 = out[k]; + let t1 = out[m + k].mul(tw[k * tw_stride]); + let t2 = out[2 * m + k].mul(tw[2 * k * tw_stride]); + let (s, d) = (t1.add(t2), t1.sub(t2)); + let a = Cpx { re: t0.re - 0.5 * s.re, im: t0.im - 0.5 * s.im }; + out[k] = t0.add(s); + // j * h * d = (-h * d.im, h * d.re) + out[m + k] = Cpx { re: a.re - h * d.im, im: a.im + h * d.re }; + out[2 * m + k] = Cpx { re: a.re + h * d.im, im: a.im - h * d.re }; + } + } + _ => { + // Generic DFT butterfly (radix 5, once per transform). + let roots: [Cpx; 5] = std::array::from_fn(|q| if q < p { tw[q * (self.n / p)] } else { Cpx::default() }); + let mut t = [Cpx::default(); 5]; + for k in 0..m { + t[0] = out[k]; + for q in 1..p { + t[q] = out[q * m + k].mul(tw[q * k * tw_stride]); + } + for q2 in 0..p { + let mut acc = t[0]; + for q in 1..p { + acc = acc.add(t[q].mul(roots[(q * q2) % p])); + } + out[q2 * m + k] = acc; + } } - out[q2 * m + k] = acc; } } } @@ -198,6 +248,26 @@ mod tests { .collect() } + #[test] + fn fft_matches_dft() { + // Every radix combination, including the 480-point size of the 20 ms frame. + for &n in &[2usize, 3, 4, 5, 6, 8, 12, 15, 16, 20, 24, 30, 48, 60, 120, 240, 480] { + let fft = Fft::new(n); + let x: Vec = (0..n).map(|i| Cpx { re: ((i * 37 % 101) as f32 - 50.0) / 7.0, im: ((i * 53 % 89) as f32 - 44.0) / 9.0 }).collect(); + let mut y = x.clone(); + fft.process(&mut y, &mut Vec::new()); + for k in 0..n { + let (mut re, mut im) = (0f64, 0f64); + for (j, v) in x.iter().enumerate() { + let ph = -2.0 * std::f64::consts::PI * ((j * k) % n) as f64 / n as f64; + re += v.re as f64 * ph.cos() - v.im as f64 * ph.sin(); + im += v.re as f64 * ph.sin() + v.im as f64 * ph.cos(); + } + assert!((y[k].re as f64 - re).abs() < 2e-3 && (y[k].im as f64 - im).abs() < 2e-3, "n={n} k={k}: ({}, {}) vs ({re}, {im})", y[k].re, y[k].im); + } + } + } + #[test] fn matches_direct_formula() { for &n in &[240usize, 960] { diff --git a/crates/opusenc/src/resample.rs b/crates/opusenc/src/resample.rs index 9045cb86d..1acc9a4e7 100644 --- a/crates/opusenc/src/resample.rs +++ b/crates/opusenc/src/resample.rs @@ -45,6 +45,25 @@ fn design(fc: f64, beta: f64, delay: usize) -> Vec { h.iter().map(|&v| v as f32).collect() } +/// Dot product with eight independent partial sums, which the compiler can vectorise (a single +/// running sum is bound by the latency of the float addition). The sums are combined pairwise. +#[inline] +fn dot8(a: &[f32], b: &[f32]) -> f32 { + let mut s = [0f32; 8]; + let (ca, ra) = a.as_chunks::<8>(); + let (cb, rb) = b.as_chunks::<8>(); + for (x, y) in ca.iter().zip(cb) { + for l in 0..8 { + s[l] += x[l] * y[l]; + } + } + let mut acc = ((s[0] + s[4]) + (s[1] + s[5])) + ((s[2] + s[6]) + (s[3] + s[7])); + for (x, y) in ra.iter().zip(rb) { + acc += x * y; + } + acc +} + /// Decimates one channel of 48 kHz audio by 3, 4 or 6. #[derive(Clone)] pub struct Downsampler { @@ -77,7 +96,7 @@ impl Downsampler { let t = taps - 1 + self.factor * j; let win = &self.buf[t + 1 - taps..=t]; // h is symmetric, so the window can be taken in forward order. - out.push(win.iter().zip(&self.h).map(|(a, b)| a * b).sum()); + out.push(dot8(win, &self.h)); } let n = self.buf.len(); self.buf.copy_within(n - (taps - 1).., 0); @@ -97,6 +116,16 @@ mod tests { (x.iter().map(|&v| (v as f64).powi(2)).sum::() / x.len() as f64).sqrt() } + #[test] + fn dot8_matches_sequential_sum() { + for len in [0usize, 1, 7, 8, 9, 63, 167, 251] { + let a: Vec = (0..len).map(|i| ((i * 37 % 101) as f32 - 50.0) / 50.0).collect(); + let b: Vec = (0..len).map(|i| ((i * 53 % 89) as f32 - 44.0) / 44.0).collect(); + let exact: f64 = a.iter().zip(&b).map(|(&x, &y)| x as f64 * y as f64).sum(); + assert!((dot8(&a, &b) as f64 - exact).abs() < 1e-4, "len {len}"); + } + } + #[test] fn passes_band_and_rejects_aliases() { for &(fs, pass, stop) in &[(16_000u32, 6_000.0, 8_600.0), (12_000, 4_500.0, 6_500.0), (8_000, 3_000.0, 4_500.0)] { diff --git a/crates/opusenc/src/silk/analysis.rs b/crates/opusenc/src/silk/analysis.rs index 439331076..d80052046 100644 --- a/crates/opusenc/src/silk/analysis.rs +++ b/crates/opusenc/src/silk/analysis.rs @@ -69,28 +69,55 @@ pub fn levinson(r: &[f64], order: usize) -> Vec { } /// Evaluates the symmetric polynomial `p` (length `d + 1`, `d` even) as the cosine series -/// `e^{jωd/2} P(e^{jω})`. -fn cos_series(p: &[f64], w: f64) -> f64 { +/// `e^{jωd/2} P(e^{jω})`, with `cos_m(m)` giving `cos(m ω)`. +#[inline] +fn cos_series_with(p: &[f64], cos_m: impl Fn(usize) -> f64) -> f64 { let h = (p.len() - 1) / 2; let mut s = p[h]; for k in 0..h { - s += 2.0 * p[k] * ((h - k) as f64 * w).cos(); + s += 2.0 * p[k] * cos_m(h - k); } s } +fn cos_series(p: &[f64], w: f64) -> f64 { + cos_series_with(p, |m| (m as f64 * w).cos()) +} + +/// Number of grid intervals the root search scans over `[0, π]`. +const GRID: usize = 1024; +/// Largest multiple of ω in the series (half the LPC order, at most 16 / 2). +const MAX_H: usize = 8; +/// Bisection steps per root: the bracket shrinks to 3e-9 rad, far below one Q15 NLSF step (9.6e-5 rad). +const BISECT_STEPS: usize = 20; + +/// `cos(m · π g / GRID)` for every grid point `g` and `m` in `0..=MAX_H`, computed with the same +/// expression as [`cos_series`], so the table values are bit-identical to calling `cos` there. +fn grid_cos() -> &'static [[f64; MAX_H + 1]] { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| { + (0..=GRID) + .map(|g| { + let w = std::f64::consts::PI * g as f64 / GRID as f64; + std::array::from_fn(|m| (m as f64 * w).cos()) + }) + .collect() + }) +} + fn roots(p: &[f64], out: &mut Vec) { - const GRID: usize = 1024; + let grid = grid_cos(); + let at_grid = |g: usize| cos_series_with(p, |m| grid[g][m]); let mut w0 = 0.0; - let mut f0 = cos_series(p, w0); + let mut f0 = at_grid(0); for g in 1..=GRID { let w1 = std::f64::consts::PI * g as f64 / GRID as f64; - let f1 = cos_series(p, w1); + let f1 = at_grid(g); if f0 == 0.0 { out.push(w0); } else if f0 * f1 < 0.0 { let (mut lo, mut hi, mut flo) = (w0, w1, f0); - for _ in 0..40 { + for _ in 0..BISECT_STEPS { let mid = 0.5 * (lo + hi); let fm = cos_series(p, mid); if fm * flo <= 0.0 { @@ -111,6 +138,9 @@ fn roots(p: &[f64], out: &mut Vec) { /// when the root search fails (unstable or degenerate filter). pub fn a2nlsf(a: &[f64]) -> Option> { let d = a.len(); + if d > 2 * MAX_H { + return None; + } let mut c = vec![0f64; d + 2]; c[0] = 1.0; for k in 0..d { @@ -199,9 +229,20 @@ pub fn pitch_search(x: &[f32], start: usize, len: usize, fs_khz: usize) -> Pitch let score = full[(lag - min_lag) as usize]; // Contour: per-subframe lag offsets around the chosen lag. let cb: &[[i32; 4]] = if fs_khz == 8 { &CB_NB_20 } else { &CB_WB_20 }; + // The contour search revisits the same (subframe, lag) pairs for neighbouring base lags and + // codebook entries, so each correlation is computed once. + let span = (18 * fs - min_lag + 1) as usize; + let memo = std::cell::RefCell::new(vec![f64::NAN; 4 * span]); let sub_score = |k: usize, l: i32| -> f64 { let l = l.clamp(min_lag, 18 * fs); - ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize) + let slot = k * span + (l - min_lag) as usize; + let cached = memo.borrow()[slot]; + if !cached.is_nan() { + return cached; + } + let v = ncorr(x, start + k * sub..start + (k + 1) * sub, l as usize); + memo.borrow_mut()[slot] = v; + v }; let mut best = (f64::MIN, lag, 0usize); for l0 in (lag - 2).max(min_lag)..=(lag + 2).min(max_lag) { @@ -319,4 +360,59 @@ mod tests { } } } + + /// The root search as it was before the grid table: `cos` called for every evaluation. + fn roots_reference(p: &[f64], out: &mut Vec) { + let mut w0 = 0.0; + let mut f0 = cos_series(p, w0); + for g in 1..=GRID { + let w1 = std::f64::consts::PI * g as f64 / GRID as f64; + let f1 = cos_series(p, w1); + if f0 == 0.0 { + out.push(w0); + } else if f0 * f1 < 0.0 { + let (mut lo, mut hi, mut flo) = (w0, w1, f0); + for _ in 0..BISECT_STEPS { + let mid = 0.5 * (lo + hi); + let fm = cos_series(p, mid); + if fm * flo <= 0.0 { + hi = mid; + } else { + lo = mid; + flo = fm; + } + } + out.push(0.5 * (lo + hi)); + } + w0 = w1; + f0 = f1; + } + } + + #[test] + fn grid_table_roots_are_bit_identical_to_direct_cos() { + let mut seed = 7u64; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + }; + let mut found = 0; + for len in [11usize, 17] { + for _ in 0..300 { + let mut p = vec![0f64; len]; + for k in 0..len / 2 + 1 { + p[k] = rnd(); + p[len - 1 - k] = p[k]; + } + let (mut a, mut b) = (Vec::new(), Vec::new()); + roots(&p, &mut a); + roots_reference(&p, &mut b); + assert_eq!(a.iter().map(|v| v.to_bits()).collect::>(), b.iter().map(|v| v.to_bits()).collect::>()); + found += a.len(); + } + } + assert!(found > 1000, "{found}"); + } } diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index c52b3d262..61fd0e6c3 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -130,13 +130,84 @@ pub fn from_kurbo(path: &BezPath) -> Vec { out } -/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). +/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it +/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with +/// round corners from the end of the top-right arc on the right edge (After Effects 2026, +/// 400 × 300 rectangles trimmed 0–25 %, case 930). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { - let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let max_rad = size[0].abs().min(size[1].abs()) / 2.0; + let (w, h) = (size[0].abs(), size[1].abs()); + let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0); + let max_rad = w.min(h) / 2.0; // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness. - let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) }; - if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } + let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } else { 0.0 }; + let mut p = BezPath::new(); + if rad <= 0.0 { + p.move_to((x1, y0)); + p.line_to((x1, y1)); + p.line_to((x0, y1)); + p.line_to((x0, y0)); + p.close_path(); + return p; + } + // Quarter circles as cubics (handle 0.5523 × radius). + let k = rad * 0.552_284_749_8; + p.move_to((x1, y0 + rad)); + p.line_to((x1, y1 - rad)); + p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1)); + p.line_to((x0 + rad, y1)); + p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad)); + p.line_to((x0, y0 + rad)); + p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0)); + p.line_to((x1 - rad, y0)); + p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad)); + p.close_path(); + p +} + +/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right, +/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii +/// are 0; radii that would overlap are scaled down as CSS `border-radius` does. +pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath { + // Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners + // clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must + // not make a trim jump (#1896). + let w = if size[0].is_finite() { size[0].abs() } else { 0.0 }; + let h = if size[1].is_finite() { size[1].abs() } else { 0.0 }; + let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0); + let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0); + let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest + // edge ratio (a uniform radius then clamps to half the shorter side, as before). + let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 }; + let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h)); + if f < 1.0 { + for v in &mut r { + *v *= f; + } + } + let [tl, tr, br, bl] = r; + // Quarter-circle Bezier control distance. + const K: f64 = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to((x1, y0 + tr)); + p.line_to((x1, y1 - br)); + if br > 0.0 { + p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1)); + } + p.line_to((x0 + bl, y1)); + if bl > 0.0 { + p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl)); + } + p.line_to((x0, y0 + tl)); + if tl > 0.0 { + p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0)); + } + p.line_to((x1 - tr, y0)); + if tr > 0.0 { + p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr)); + } + p.close_path(); + p } pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { @@ -369,6 +440,42 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + /// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain: + /// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom. + /// Round corners 40: from the end of the top-right arc (200, −110) down, round the + /// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %. + #[test] + fn rectangles_start_where_after_effects_starts_them() { + let ends = |p: &BezPath| { + let mut first = None; + let mut last = None; + for el in p.elements() { + match *el { + PathEl::MoveTo(q) if first.is_none() => first = Some(q), + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q), + PathEl::ClosePath => {} + } + } + (first.unwrap_or_default(), last.unwrap_or_default()) + }; + let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0; + let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&plain[0]); + assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}"); + let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&round[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Per-corner roundness starts and runs in the same order: ticking the checkbox must not + // move a Trim Paths contour. + let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0); + let (a, b) = ends(&corners[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}"); + // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). + let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); + let (a, b) = ends(&swapped[0]); + assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}"); + } + #[test] fn rect_with_nan_size_does_not_panic() { let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0); @@ -400,6 +507,32 @@ mod tests { assert!((area - 200.0).abs() < 1.0, "{area}"); } + #[test] + fn rect_radii_uniform_matches_a_rounded_rect() { + for r in [0.0, 5.0, 25.0, 100.0] { + let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]); + let clamped = r.min(25.0); + let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped; + assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area()); + assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6); + } + } + + #[test] + fn rect_radii_mixes_corners_and_scales_overlaps() { + // Only the top-left and bottom-right corners round. + let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]); + let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0); + assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area()); + // Radii wider than the top edge scale down together, like CSS border-radius. + let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]); + let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]); + assert!((p.area() - scaled.area()).abs() < 1e-9); + // Negative and non-finite radii are square corners. + let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]); + assert!((p.area().abs() - 2500.0).abs() < 1e-9); + } + #[test] fn stroke_has_area() { let r = rect([40.0, 40.0], [50.0, 50.0], 0.0); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index b78e3d8c1..1aa8d7c90 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec) -> PropGrou } pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup { + // Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape. + let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 }; indexed(ids.group("rect", "Rectangle Path 1")) .with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden)) .with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels)) .with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point)) .with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels)) + .with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) } pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup { @@ -596,6 +603,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(wiggle_spatial_phase(ids)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), + "wiggler" | "wiggleTransform" => { + // The Transform group holds how far each transformation wiggles. + let tr = ids + .group("transform", "Transform") + .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) + .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + indexed(ids.group("wiggler", "Wiggle Transform 1")) + .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))) + .with(tr) + } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add", @@ -681,10 +704,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { } if kind == LightKind::Environment { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; - // none = the comp's Environment Layer. + // none = the comp's Environment Layer, or a flat default environment of the light's + // colour × intensity when there is none. No Falloff: an environment light shines from + // every direction at once. Environment lights cast shadows too (After Effects' Light + // Settings shows Cast Shadows for them; not for Ambient) (#2436). return g .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer)) - .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)); } if kind != LightKind::Ambient { g = g @@ -881,6 +910,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -910,7 +947,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 5a3c8650e..aa4ff7201 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -87,8 +87,8 @@ pub struct Comp { pub display_start: Tick, pub background: [f32; 3], pub work_area: (Tick, Tick), - /// Layer #1 first (top of the stack). - pub layers: Vec, + /// Layer #1 first (top of the stack). Each layer is shared between undo states until edited. + pub layers: super::Layers, #[serde(default)] pub markers: Vec, pub shutter_angle: f64, @@ -188,7 +188,7 @@ impl Comp { display_start: Tick::ZERO, background: [0.0, 0.0, 0.0], work_area: (Tick::ZERO, duration), - layers: vec![], + layers: super::Layers::new(), markers: vec![], shutter_angle: 180.0, shutter_phase: -90.0, @@ -239,7 +239,7 @@ impl Comp { } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.layers.iter_mut().find(|l| l.id == id) + self.layers.find_mut(|l| l.id == id) } /// 1-based layer number (#) of a layer. @@ -275,15 +275,35 @@ impl Comp { } } +/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it is +/// part of the pixels even where plain markers are not. +fn is_timing_marker(m: &Marker) -> bool { + m.protected && m.duration > Tick::ZERO +} + +fn timing_markers(m: &[Marker]) -> impl Iterator + '_ { + m.iter().filter(|m| is_timing_marker(m)).map(|m| (m.time, m.duration)) +} + impl Comp { /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { - self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + self.same_pixels_with(o, true) } - /// [`Self::same_pixels`] but for the layers. - fn same_settings(&self, o: &Comp) -> bool { + /// [`Self::same_pixels`], with the comp's and its layers' markers counting only when + /// `with_markers`: only expressions read them, so while none does, adding or moving a marker + /// (even during playback) leaves every rendered frame valid. Protected time markers with a + /// duration set the timing (Responsive Design), so those always count. + pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool { + self.same_settings(o, with_markers) + && self.layers.len() == o.layers.len() + && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers)) + } + + /// [`Self::same_pixels_with`] but for the layers. + fn same_settings(&self, o: &Comp, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here). let Comp { width, @@ -321,7 +341,7 @@ impl Comp { && *display_start == o.display_start && *background == o.background && *work_area == o.work_area - && *markers == o.markers + && (if with_markers { *markers == o.markers } else { timing_markers(markers).eq(timing_markers(&o.markers)) }) && *shutter_angle == o.shutter_angle && *shutter_phase == o.shutter_phase && *motion_blur_samples == o.motion_blur_samples @@ -356,7 +376,7 @@ impl Comp { }) } // Solo hides every other layer at every frame. - if !self.same_settings(o) || self.any_solo() != o.any_solo() { + if !self.same_settings(o, true) || self.any_solo() != o.any_solo() { return None; } let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::>(); @@ -393,34 +413,33 @@ impl Comp { self.layers.iter().any(|l| l.switches.solo && l.source.is_av()) } - /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected - /// time markers with a duration set the timing (Responsive Design), so those still count. - pub fn same_pixels_ignoring_markers(&self, o: &Comp) -> bool { - let protected = - |m: &[Marker]| -> Vec<(Tick, Tick)> { m.iter().filter(|m| m.protected && m.duration > Tick::ZERO).map(|m| (m.time, m.duration)).collect() }; - if protected(&self.markers) != protected(&o.markers) { - return false; - } - let mut c = self.clone(); - c.markers = o.markers.clone(); - for (a, b) in c.layers.iter_mut().zip(&o.layers) { - a.markers = b.markers.clone(); - a.markers_locked = b.markers_locked; - } - c.same_pixels(o) - } - /// The comp with those switches and the Resolution at their defaults (borrowed when they /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + self.pixel_form_with(true) + } + + /// [`Self::pixel_form`], also without the comp's plain markers and its layers' markers when not + /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]). + pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> { + if !self.hide_shy + && self.resolution.is_auto() + && self.layers.iter().all(|l| l.switches == l.switches.pixels()) + && (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty()))) + { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; c.resolution = Resolution::Auto; + if !with_markers { + c.markers.retain(is_timing_marker); + } for l in &mut c.layers { l.switches = l.switches.pixels(); + if !with_markers { + l.markers.clear(); + } } std::borrow::Cow::Owned(c) } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 391b1e17c..8cf07ea2e 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,4 +1,4 @@ -use super::settings::yes; +use super::settings::{Marker, yes}; use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -44,6 +44,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] @@ -52,6 +57,142 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the + /// converted black and white levels (0–1; above 1 for over-range highlights). + pub fn convert(&self, v: f32) -> f32 { + let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff); + let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; + if out.is_finite() { out } else { 0.0 } + } +} + +/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter +/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff +/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes +/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default +/// Gamma 1.7 gives linear light). +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(scale / gamma.max(0.01)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -98,6 +239,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -115,6 +259,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -145,16 +293,29 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 3f53bc823..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -98,6 +98,21 @@ impl Default for Project { } impl Project { + /// Some layer has an expression, which can read markers (`marker.key(1)`, `numKeys`, + /// `nearestKey`...). Only then do the markers of comps and layers affect what is rendered, so + /// frame caches ignore them otherwise. + pub fn reads_markers(&self) -> bool { + // Called for every frame identity and disk-cache key: stops at the first expression and + // builds no paths (`PropGroup::walk` formats one per node). + fn any_expr(g: &crate::PropGroup) -> bool { + g.children.iter().any(|c| match c { + crate::Node::Prop(p) => p.expr.is_some(), + crate::Node::Group(sub) => any_expr(sub), + }) + } + self.comps().any(|(_, c)| c.layers.iter().any(|l| any_expr(&l.props))) + } + /// Allocate an id (items, layers and property uids share one counter). pub fn alloc(&mut self) -> u64 { let id = self.next_id; @@ -252,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -274,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 25e2da7c4..21307583d 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -195,6 +195,12 @@ impl Switches { impl Layer { /// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches. pub fn same_pixels(&self, o: &Layer) -> bool { + self.same_pixels_with(o, true) + } + + /// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by + /// expressions alone, so a project without any can edit them freely). + pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here); the property tree last. let Layer { id, @@ -232,7 +238,7 @@ impl Layer { && *preserve_transparency == o.preserve_transparency && *track_matte == o.track_matte && *parent == o.parent - && *markers == o.markers + && (!with_markers || *markers == o.markers) && *markers_locked == o.markers_locked && *auto_orient == o.auto_orient && *environment == o.environment diff --git a/crates/project/src/layers.rs b/crates/project/src/layers.rs new file mode 100644 index 000000000..6119a5753 --- /dev/null +++ b/crates/project/src/layers.rs @@ -0,0 +1,209 @@ +//! A comp's layer stack with structural sharing. +//! +//! Every undoable edit keeps the previous project for undo, so the comp it touches is shared and +//! `Arc::make_mut` copies it. With the layers stored inline that copy is the whole stack, every +//! layer and every keyframe, so an edit costs time in proportion to the comp's size and a script +//! that sets many keys in a big comp goes quadratic. Here each layer sits behind its own `Arc`: +//! copying the stack copies pointers, and only the layer an edit actually changes is copied. +//! +//! The API mirrors the `Vec` it replaces. Serialized form is identical (a list of layers). + +use std::ops::{Index, IndexMut}; +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::layer::Layer; + +/// Layer #1 first (top of the stack). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layers(Vec>); + +impl Layers { + pub const fn new() -> Self { + Self(Vec::new()) + } + pub fn with_capacity(n: usize) -> Self { + Self(Vec::with_capacity(n)) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn iter(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.0.iter().map(|l| &**l) + } + /// Mutable access to every layer: each one that is still shared with an undo state is copied, + /// so prefer [`Layers::get_mut`] (or `iter` to find, then `get_mut`) when only some change. + pub fn iter_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter_mut().map(Arc::make_mut) + } + /// Mutable access to just the layers `keep` accepts. `keep` reads each layer without copying + /// it; only the matching ones are copied (if shared). Use this, not `iter_mut().filter(..)`. + pub fn iter_mut_where<'a>(&'a mut self, mut keep: impl FnMut(&Layer) -> bool + 'a) -> impl Iterator + 'a { + self.0.iter_mut().filter(move |l| keep(l)).map(Arc::make_mut) + } + /// The first layer `pred` accepts, copied only if it is that one. Use this, not `iter_mut().find(..)`. + pub fn find_mut(&mut self, mut pred: impl FnMut(&Layer) -> bool) -> Option<&mut Layer> { + self.0.iter_mut().find(|l| pred(l)).map(Arc::make_mut) + } + pub fn get(&self, i: usize) -> Option<&Layer> { + self.0.get(i).map(|l| &**l) + } + pub fn get_mut(&mut self, i: usize) -> Option<&mut Layer> { + self.0.get_mut(i).map(Arc::make_mut) + } + pub fn first(&self) -> Option<&Layer> { + self.get(0) + } + pub fn last(&self) -> Option<&Layer> { + self.0.last().map(|l| &**l) + } + pub fn first_mut(&mut self) -> Option<&mut Layer> { + self.get_mut(0) + } + pub fn last_mut(&mut self) -> Option<&mut Layer> { + self.0.last_mut().map(Arc::make_mut) + } + pub fn push(&mut self, l: Layer) { + self.0.push(Arc::new(l)); + } + pub fn insert(&mut self, i: usize, l: Layer) { + self.0.insert(i, Arc::new(l)); + } + pub fn remove(&mut self, i: usize) -> Layer { + Arc::unwrap_or_clone(self.0.remove(i)) + } + pub fn pop(&mut self) -> Option { + self.0.pop().map(Arc::unwrap_or_clone) + } + pub fn retain(&mut self, mut f: impl FnMut(&Layer) -> bool) { + self.0.retain(|l| f(l)); + } + pub fn swap(&mut self, a: usize, b: usize) { + self.0.swap(a, b); + } + pub fn truncate(&mut self, n: usize) { + self.0.truncate(n); + } + pub fn clear(&mut self) { + self.0.clear(); + } + pub fn reverse(&mut self) { + self.0.reverse(); + } + pub fn sort_by_key(&mut self, mut f: impl FnMut(&Layer) -> K) { + self.0.sort_by_key(|l| f(l)); + } + pub fn sort_by(&mut self, mut f: impl FnMut(&Layer, &Layer) -> std::cmp::Ordering) { + self.0.sort_by(|a, b| f(a, b)); + } + pub fn rotate_left(&mut self, n: usize) { + self.0.rotate_left(n); + } + pub fn rotate_right(&mut self, n: usize) { + self.0.rotate_right(n); + } + /// `layers[range].rotate_left(n)` (a slice of `Arc`s can't be handed out as a `&mut [Layer]`). + pub fn rotate_range_left(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_left(n); + } + } + pub fn rotate_range_right(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_right(n); + } + } + pub fn split_off(&mut self, at: usize) -> Layers { + Layers(self.0.split_off(at)) + } + pub fn append(&mut self, other: &mut Layers) { + self.0.append(&mut other.0); + } + /// Remove a range of layers and return them (owned). + pub fn drain(&mut self, r: impl std::ops::RangeBounds) -> std::vec::IntoIter { + self.0.drain(r).map(Arc::unwrap_or_clone).collect::>().into_iter() + } + /// Whether `self` and `other` hold the very same (unchanged) layer at `i`: a cheap "did this + /// layer change" test between two project states. + pub fn same_at(&self, other: &Layers, i: usize) -> bool { + matches!((self.0.get(i), other.0.get(i)), (Some(a), Some(b)) if Arc::ptr_eq(a, b)) + } + /// The shared layers themselves, for parallel iteration (`rayon` over `&[Arc]`). + pub fn as_arcs(&self) -> &[Arc] { + &self.0 + } + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } +} + +impl Index for Layers { + type Output = Layer; + fn index(&self, i: usize) -> &Layer { + &self.0[i] + } +} + +impl IndexMut for Layers { + fn index_mut(&mut self, i: usize) -> &mut Layer { + Arc::make_mut(&mut self.0[i]) + } +} + +impl From> for Layers { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(Arc::new).collect()) + } +} + +impl FromIterator for Layers { + fn from_iter>(it: I) -> Self { + Self(it.into_iter().map(Arc::new).collect()) + } +} + +impl Extend for Layers { + fn extend>(&mut self, it: I) { + self.0.extend(it.into_iter().map(Arc::new)); + } +} + +impl IntoIterator for Layers { + type Item = Layer; + type IntoIter = std::iter::Map>, fn(Arc) -> Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter().map(Arc::unwrap_or_clone as fn(Arc) -> Layer) + } +} + +impl<'a> IntoIterator for &'a Layers { + type Item = &'a Layer; + type IntoIter = std::iter::Map>, fn(&'a Arc) -> &'a Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter().map(|l| &**l) + } +} + +impl<'a> IntoIterator for &'a mut Layers { + type Item = &'a mut Layer; + type IntoIter = std::iter::Map>, fn(&'a mut Arc) -> &'a mut Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut().map(Arc::make_mut) + } +} + +impl Serialize for Layers { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Layers { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Layers::from) + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..6cfbda344 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,8 +1,8 @@ //! The EffectCraft document model. //! //! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree -//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not -//! change. +//! ([`props`]). Compositions, and each layer within them ([`Layers`]), sit behind `Arc` so undo +//! snapshots share everything that did not change. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -33,12 +33,14 @@ mod comp; mod footage; mod item; mod layer; +mod layers; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; +pub use layers::Layers; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..be2468c37 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -205,7 +205,8 @@ pub enum MaskMotionBlur { Off, } -/// How a mask's feather ramps: Smooth (Gaussian) or Linear. +/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as +/// measured in After Effects) or Linear (one box pass: a straight ramp). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FeatherFalloff { #[default] diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..1a829630c 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,9 +442,18 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -509,6 +518,9 @@ pub enum OutputFormat { TiffSequence, /// OpenEXR sequence (32-bit float, linear light). ExrSequence, + /// Targa sequence: uncompressed 24-bit RGB or 32-bit RGB + Alpha (8 alpha bits), top-left + /// origin. + TargaSequence, /// Animated GIF. Gif, /// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio. @@ -528,7 +540,7 @@ pub enum OutputFormat { } impl OutputFormat { - pub const ALL: [OutputFormat; 13] = [ + pub const ALL: [OutputFormat; 14] = [ OutputFormat::H264, OutputFormat::Hevc, OutputFormat::Av1, @@ -539,6 +551,7 @@ impl OutputFormat { OutputFormat::JpegSequence, OutputFormat::TiffSequence, OutputFormat::ExrSequence, + OutputFormat::TargaSequence, OutputFormat::Gif, OutputFormat::Wav, OutputFormat::Aiff, @@ -552,6 +565,7 @@ impl OutputFormat { OutputFormat::JpegSequence => "JPEG Sequence", OutputFormat::TiffSequence => "TIFF Sequence", OutputFormat::ExrSequence => "OpenEXR Sequence", + OutputFormat::TargaSequence => "Targa Sequence", OutputFormat::Gif => "Animated GIF", OutputFormat::WebM => "WebM (VP9 + Opus)", OutputFormat::Wav => "WAV", @@ -569,6 +583,7 @@ impl OutputFormat { OutputFormat::JpegSequence => "jpg", OutputFormat::TiffSequence => "tif", OutputFormat::ExrSequence => "exr", + OutputFormat::TargaSequence => "tga", OutputFormat::Gif => "gif", OutputFormat::WebM => "webm", OutputFormat::Wav => "wav", @@ -577,7 +592,10 @@ impl OutputFormat { } } pub fn is_sequence(self) -> bool { - matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) + matches!( + self, + OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence | OutputFormat::TargaSequence + ) } pub fn is_movie(self) -> bool { matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1) @@ -603,7 +621,7 @@ impl OutputFormat { if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) } } /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`, - /// `tiff`/`tif`, `exr`, `gif`, or a label. + /// `tiff`/`tif`, `exr`, `tga`/`targa`, `gif`, or a label. pub fn from_name(s: &str) -> Option { let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], ""); Some(match n.as_str() { @@ -616,6 +634,7 @@ impl OutputFormat { "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence, "exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence, + "tga" | "targa" | "targasequence" => OutputFormat::TargaSequence, "gif" | "animatedgif" => OutputFormat::Gif, "webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM, "wav" | "wave" => OutputFormat::Wav, @@ -769,15 +788,18 @@ impl Crop { if self.use_roi && let Some([x, y, rw, rh]) = self.roi { - // The quad arrives unvalidated (control channel or a hand-edited project): - // clamp like the sibling edge fields (plus/minus 20 000) and subtract - // without overflow. - let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32; - let l = conv(x * scale); - let t = conv(y * scale); - let r = (w as i32).saturating_sub(conv((x + rw) * scale)); - let b = (h as i32).saturating_sub(conv((y + rh) * scale)); - return (t, l, b, r); + // The quad arrives unvalidated (control channel or a hand-edited project). As in + // After Effects, the region of interest lies inside the frame: each converted edge + // is clamped to [0, w] / [0, h] (inf clamps to a side, NaN converts to 0), and the + // far edge never passes the near one. Every edge then lies inside the frame, so the + // crop can't be larger than the frame and no subtraction can overflow. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let conv = |v: f64, side: i32| v.round().clamp(0.0, f64::from(side)) as i32; + let l = conv(x * scale, wi); + let t = conv(y * scale, hi); + let r = conv((x + rw) * scale, wi).max(l); + let b = conv((y + rh) * scale, hi).max(t); + return (t, l, hi - b, wi - r); } (self.top, self.left, self.bottom, self.right) } @@ -1116,7 +1138,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1649,8 +1671,8 @@ mod tests { #[test] fn hostile_roi_cannot_overflow_crop_math() { - // `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate, - // not overflow, and still yield a usable (degenerate) frame size. + // `-1e12` is clamped into the frame on conversion: it must not overflow and must still + // yield a usable (degenerate) frame size. let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); let rs = RenderSettings::default(); let mut m = OutputModule::for_format(OutputFormat::PngSequence); @@ -1663,6 +1685,66 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + let m = OutputModule::for_format(OutputFormat::PngSequence); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); + } + + #[test] + fn valid_wide_roi_endpoints_are_preserved() { + // A supported 30 000 x 100 frame at scale 1: ROI [10000, 0, 20000, 100] spans x 10 000..30 000 + // and must give 20 000 x 100 (the absolute right edge 30 000 exceeds the old +/-20 000 clamp). + let c = Comp::new(30_000, 100, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::for_format(OutputFormat::PngSequence); + m.crop = Crop { enabled: true, use_roi: true, roi: Some([10_000.0, 0.0, 20_000.0, 100.0]), ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (20_000, 100)); + assert_eq!(m.crop.edges(30_000, 100, 1.0), (0, 10_000, 0, 0)); + // Scale 4.0 pushes a valid edge to 120 000 px (30 000 x 4); the left edge 40 000 and the + // right edge 120 000 are both beyond 20 000 and must survive. + assert_eq!(m.crop.edges(120_000, 400, 4.0), (0, 40_000, 0, 0)); + assert_eq!(m.frame_size(120_000, 400, 4.0), (80_000, 400)); + // Hostile quads stay inside the frame: never empty, never larger than the frame, so the + // export can't be asked for an unbounded crop (an ROI reaching far outside the comp + // used to pad the frame instead). + let (fw, fh) = (1920, 1080); + let hostile = [ + [1e300, 1e300, 1e300, 1e300], + [-1e300, -1e300, 1e300, 1e300], + [-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0], + [-1e12, -1e12, -1e12, -1e12], + [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN], + [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY], + ]; + for roi in hostile { + m.crop.roi = Some(roi); + let (t, l, b, r) = m.crop.edges(fw, fh, 1.0); + assert!([t, l, b, r].iter().all(|e| *e >= 0), "{roi:?}: an edge pads the frame: {:?}", (t, l, b, r)); + let (w, h) = m.frame_size(fw, fh, 1.0); + assert!((1..=fw).contains(&w) && (1..=fh).contains(&h), "{roi:?}: {w}x{h} is outside 1x1..={fw}x{fh}"); + } + // An ROI covering more than the frame is the whole frame. + m.crop.roi = Some([-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0]); + assert_eq!(m.frame_size(fw, fh, 1.0), (fw, fh)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); @@ -1682,6 +1764,28 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap().format, f); } + #[test] + fn targa_sequence_format() { + let f = OutputFormat::from_name("tga").unwrap(); + assert_eq!(f, OutputFormat::TargaSequence); + assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f)); + assert_eq!(OutputFormat::from_name("Targa"), Some(f)); + assert_eq!(OutputFormat::from_name("Targa Sequence"), Some(f)); + assert_eq!(OutputFormat::from_path("/out/shot_00001.TGA"), Some(f)); + assert!(OutputFormat::ALL.contains(&f)); + assert!(f.is_sequence() && f.supports_alpha() && !f.supports_audio() && !f.is_movie()); + assert_eq!((f.label(), f.extension()), ("Targa Sequence", "tga")); + assert_eq!(f.coded_size(3, 5), (3, 5)); + // A movie name becomes a numbered sequence; RGB + Alpha survives the switch. + let mut m = OutputModule::for_format(OutputFormat::ProRes); + m.output = "/out/Comp 1.mov".into(); + m.channels = Channels::Rgba; + m.set_format(f); + assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1_[#####].tga", Channels::Rgba)); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap().format, f); + } + #[test] fn hevc_av1_codec_options() { assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc)); diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs index 83db5e581..2b2bdf92c 100644 --- a/crates/project/src/styles.rs +++ b/crates/project/src/styles.rs @@ -470,7 +470,7 @@ mod tests { let mut ids = Ids(&mut p.next_id); add_style(&mut a, &mut ids, "dropShadow", &gl, true); add_style(&mut b, &mut ids, "bevelEmboss", &gl, true); - comp.layers = vec![a, b]; + comp.layers = vec![a, b].into(); comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0); assert!(needs_sync(&comp)); sync_comp(&mut comp); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index f9ee16167..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { 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], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).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. @@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(!p.comp_contains(c[n - 1], c[0])); } +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} + /// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. #[test] fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 9f2e4e755..fd2fd183b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -23,6 +23,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa pub type Px = [f32; 4]; +/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000; +/// raster sits below it in the layering and may not depend on it, so the two must stay equal). +/// A padded side beyond the largest supported comp side has no render use. +pub const MAX_PADDED_SIDE: u32 = 30_000; + /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. @@ -161,13 +166,28 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side + /// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so + /// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side + /// already past the cap gets pad 0). Callers that track where the pixels went (a buffer + /// offset) must move by this, not by the requested pad. + pub fn effective_pad(&self, pad: u32) -> u32 { + pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2) + } + /// Copy with [`Image::effective_pad`] transparent pixels on every side. + /// + /// The clamp keeps the output within the largest image the engine supports (a max-size comp + /// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory + /// budget: callers that pad by a derived, possibly large amount bound the area themselves + /// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an + /// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let eff = self.effective_pad(pad); + let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; - let o = (y + pad as usize) * ow + pad as usize; + let o = (y + eff as usize) * ow + eff as usize; out.data[o..o + self.width as usize].copy_from_slice(src); } out @@ -222,7 +242,7 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { @@ -231,9 +251,20 @@ impl Image { o[3] = (a * 255.0 + 0.5) as u8; }) } + /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded. + pub fn to_rgba8_dithered(&self) -> Vec { + self.map_rgba8(|o, p, x, y| { + let a = p[3].clamp(0.0, 1.0); + let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; + for c in 0..3 { + o[c] = dither_u8(p[c] * inv, x, y); + } + o[3] = (a * 255.0 + 0.5) as u8; + }) + } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; @@ -241,13 +272,23 @@ impl Image { o[3] = 255; }) } - /// 8-bit RGBA with each pixel written by `f`, row-parallel. - fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]). + pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec { + self.map_rgba8(|o, p, x, y| { + let k = 1.0 - p[3].clamp(0.0, 1.0); + for c in 0..3 { + o[c] = dither_u8(p[c] + bg[c] * k, x, y); + } + o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec { let mut out = vec![0u8; self.data.len() * 4]; let w = self.width.max(1) as usize; - out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { - for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { - f(o, p); + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| { + for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() { + f(o, p, x as u32, y as u32); } }); out @@ -322,6 +363,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 { (h & 0x00ff_ffff) as f32 / 16_777_216.0 } +/// Seed of the output dither: fixed, so a frame converts to the same bytes every time. +const DITHER_SEED: u32 = 0x5bd1_e995; + +/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is +/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of +/// a smooth gradient average to the gradient instead of stepping in bands. A value already on +/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only +/// on the value and the pixel position, never on a clock or a global generator. +#[inline] +pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 { + let s = v.clamp(0.0, 1.0) * 255.0; + // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it. + let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4; + (s + t).floor() as u8 +} + /// A single-channel coverage mask. #[derive(Clone, Debug, Default, PartialEq)] pub struct Mask { @@ -346,6 +403,35 @@ impl Mask { mod tests { use super::*; + #[test] + fn dither_keeps_exact_codes_and_averages_the_rest() { + // A value on an 8-bit code is that code at every position. + for code in 0..=255u32 { + let v = code as f32 / 255.0; + for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] { + assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})"); + } + } + // Out-of-range and NaN values saturate instead of wrapping. + assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0)); + // A value between two codes lands on both, in proportion: the mean over a block is the value. + let v = (100.0 + 0.3) / 255.0; + let n = 64u32; + let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum(); + let mean = sum as f32 / (n * n) as f32; + assert!((mean - 100.3).abs() < 0.03, "{mean}"); + // Deterministic. + assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4)); + } + + #[test] + fn dithered_conversions_match_the_plain_ones_on_exact_codes() { + let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255]; + let img = Image::from_rgba8(2, 2, &src); + assert_eq!(img.to_rgba8_dithered(), img.to_rgba8()); + assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3])); + } + #[test] fn rgba8_roundtrip() { let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4]; @@ -384,4 +470,29 @@ mod tests { assert!((0.0..1.0).contains(&n)); } } + + #[test] + fn padded_bounds_hostile_pad_at_the_api() { + // Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an + // oversized buffer here — the very OOM this guard exists to stop, now caught in padded(). + // (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.) + let mut img = Image::new(29_990, 2); + img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(u32::MAX); + // Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12)); + // The source lands intact at the clamped offset, not the requested one. + assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent + } + + #[test] + fn padded_leaves_legit_pads_untouched() { + // Anti-over-clamp: an ordinary pad must round exactly, byte for byte. + let mut img = Image::new(8, 6); + img.set(3, 2, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(4); + assert_eq!((out.width, out.height), (16, 14)); + assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4) + } } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index accc1fc54..e6bbd2113 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -131,6 +131,14 @@ pub trait FxTarget { buf }); } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -184,8 +192,9 @@ impl<'a> Renderer<'a> { pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { match &layer.source { LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; - let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + let a = self.footage.aux(item, f, ctx.source_time(layer))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -332,8 +341,8 @@ impl<'a> Renderer<'a> { } /// Draw a collapsed precomp layer into `canvas` on the CPU. - pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { - self.draw_collapsed(ctx, layer, item, canvas, false); + pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) { + self.draw_collapsed(ctx, layer, collapsed, canvas, false); } /// The layer's finished buffers (styles included) in the blending space. diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..e2861340c 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -4,10 +4,13 @@ //! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before //! its levels; Backwards plays the layer's [in, out) span reversed. //! -//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels -//! are evaluated once per block. To make the result independent of block boundaries, layer -//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's -//! tail is kept — so preview playback and export produce identical samples. +//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export). +//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects +//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every +//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the +//! result independent of block boundaries, layer effects are run from a cleared state over a +//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export +//! produce identical samples. //! //! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are //! silent for now. @@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 } +/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on +/// a grid of comp sample indices so the result does not depend on where a block starts. +fn param_step(rate: u32) -> i64 { + i64::from(rate / 1000).max(1) +} + +/// Does any property of the group change over time? +fn animated(g: &effectcraft_project::PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.is_animated() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) +} + fn sample_tick(i: i64, rate: u32) -> Tick { // Keep the requested sample when the rate does not divide the tick clock. let t = Tick::from_units(i, i64::from(rate)); @@ -189,21 +206,70 @@ impl MixEnv<'_> { /// `i0 .. i0 + n`. fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec { let t = sample_tick(i0, self.rate); - let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect(); + let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect(); if fx.is_empty() { return self.layer_raw(l, i0, n, false); } - let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum(); + let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); let pre = (pre_s * self.rate as f64).ceil() as usize; - let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed); - let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds(); - for (id, p) in &fx { - audio_fx::process(id, p, &mut buf, self.rate, start); + let b0 = i0 - pre as i64; + let mut buf = self.layer_raw(l, b0, n + pre, reversed); + let start = l.layer_time(sample_tick(b0, self.rate)).seconds(); + for (id, g, p) in &fx { + if audio_fx::per_sample(id) && animated(g) { + self.process_stepped(l, id, g, &mut buf, b0); + } else { + audio_fx::process(id, p, &mut buf, self.rate, start); + } } buf.drain(..pre * 2); buf } + + /// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter + /// grid, each with the parameters at its first sample. + fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) { + let step = param_step(self.rate); + let frames = buf.len() / 2; + let mut k = 0usize; + while k < frames { + let i = b0.saturating_add(k as i64); + let next = i.div_euclid(step).saturating_add(1).saturating_mul(step); + let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames); + let t = sample_tick(i, self.rate); + if let Some(piece) = buf.get_mut(k * 2..end * 2) { + audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds()); + } + k = end; + } + } + + /// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`. + /// Animated levels are evaluated on the parameter grid and interpolated in dB in between. + fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> { + let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] }; + let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2(); + let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)]; + if !pr.is_animated() && !pr.has_expression() { + return vec![gain(db(i0)); n]; + } + let step = param_step(self.rate); + let end = i0.saturating_add(n as i64); + let mut out = Vec::with_capacity(n); + let mut k = i0.div_euclid(step).saturating_mul(step); + let mut lo = db(k); + while k < end { + let hi = db(k.saturating_add(step)); + for i in k.max(i0)..k.saturating_add(step).min(end) { + let f = (i - k) as f64 / step as f64; + out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f])); + } + k = k.saturating_add(step); + lo = hi; + } + out + } } /// Shared inputs for composition audio mixing, including nested compositions. @@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut if a >= b { continue; } - let t_a = sample_tick(s0 + a as i64, rate); - let (ll, lr) = levels(&env, l, t_a); - if ll == 0.0 && lr == 0.0 { + let gains = env.level_gains(l, s0 + a as i64, b - a); + if gains.iter().all(|g| *g == [0.0, 0.0]) { continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { - out[(a + i) * 2] += f[0] * ll; - out[(a + i) * 2 + 1] += f[1] * lr; + for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() { + out[(a + i) * 2] += f[0] * g[0]; + out[(a + i) * 2 + 1] += f[1] * g[1]; } } } -fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) { - let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; - let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]); - (db_to_gain(a), db_to_gain(b)) -} - /// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per /// `bin` sample frames, `[min L, max L, min R, max R]`. pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { @@ -347,7 +406,7 @@ mod tests { pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]); } let mut comp = comp; - comp.layers = vec![l1, l2]; + comp.layers = vec![l1, l2].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); assert!(comp_has_audio(&p, cid)); let rate = 1000; @@ -393,11 +452,11 @@ mod tests { let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); let mut inner = base.clone(); - inner.layers = vec![tone]; + inner.layers = vec![tone].into(); let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into())); let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None); let mut outer = base.clone(); - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into())); let rate = 8000; let t = Tick::from_seconds_f64(0.5); @@ -424,7 +483,7 @@ mod tests { let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); tone.stretch = stretch; let mut comp = base.clone(); - comp.layers = vec![tone]; + comp.layers = vec![tone].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let n = 2000usize; let t = Tick::from_seconds_f64(0.5); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 21f745c0e..b6f2e4076 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { - return group_has(c, "wiggle"); + return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). @@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. +/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values +/// without editing the layer, so a key without them would reuse another instance's buffer. +fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) { + for o in ctx.property_overrides.iter().filter(|o| o.layer == id) { + o.prop.hash(h); + o.font.hash(h); + hash_debug(h, &o.value); + } + for m in ctx.media_overrides.iter().filter(|m| m.layer == id) { + m.item.hash(h); + } +} + fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { @@ -497,10 +510,11 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - // Time effects see property values at other times. Keyframes are part of the hashed - // structure (and the layer time is folded in), but expressions may read other layers at - // those times, which the key cannot see: such layers are not cached. - if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { + // Time effects (and CC Particle World's Birth Rate history) see property values at other + // times. Keyframes are part of the hashed structure (and the layer time is folded in), but + // expressions may read other layers at those times, which the key cannot see: such layers + // are not cached. + if history_unkeyable(layer, None) { return None; } key_with(ctx, layer, scale, draft, blur, false) @@ -510,7 +524,7 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { - if layer.switches.adjustment { + if layer.switches.adjustment || history_unkeyable(layer, Some(effects)) { return None; } let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; @@ -519,14 +533,26 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo Some(h.finish()) } -/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)? -fn reads_other_times(layer: &Layer) -> bool { +/// Does the layer run (among its first `effects` effects, all when `None`) an effect that reads +/// the layer at other times (Echo, Timewarp…) or its own parameters' history (CC Particle +/// World's Birth Rate, #1373)? +fn reads_other_times(layer: &Layer, effects: Option) -> bool { layer.switches.effects && layer.effects().is_some_and(|fx| { - fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time."))) + fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| { + g.enabled + && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } + if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect)) + }) }) } +/// The layer reads values at other times (see [`reads_other_times`]) and has expressions +/// (outside Transform), whose results at those times the key can't see. +fn history_unkeyable(layer: &Layer, effects: Option) -> bool { + reads_other_times(layer, effects) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) +} + /// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU /// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every /// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only. @@ -592,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { - let it = ctx.project.item(*item)?; + LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => { + let item = ctx.media_item(layer, *item); + let it = ctx.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. @@ -616,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); + // Collapsed-instance overrides replace values without editing the nested layer, so mix them + // into the key: a Font override, effect parameter or any other source-affecting override + // must not reuse another instance's processed buffer. + hash_overrides(&mut h, ctx, layer.id); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 483b19da3..1a550f9c7 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,11 +3,11 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { - let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) }; + let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer, None) }; deps.effects(ctx, layer, effects) } @@ -21,7 +21,7 @@ struct Dependencies<'a> { impl Dependencies<'_> { fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option) -> Option<()> { let previous = self.other_times; - self.other_times |= super::reads_other_times(layer); + self.other_times |= super::reads_other_times(layer, None); if layer.switches.effects && let Some(fx) = layer.effects() { @@ -63,19 +63,79 @@ impl Dependencies<'_> { Some(()) } + /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself + /// (geometry, materials, switches, in/out), its animated values and transform at the time, + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. + fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + self.remaining = self.remaining.checked_sub(1)?; + // Expressions sampled at other times: not cacheable, as in the ordinary path. + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { + return None; + } + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if let Some(item) = layer.source.item() { + hash_debug(self.h, &ctx.project.item(item)); + } + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. + hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); + hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); + hash_debug(self.h, &ctx.comp.draft_3d); + hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } + // Outside the layer's in/out range the read is transparent: fold the gate in, so a + // static model renders once inside it and again when it leaves. + layer.is_active_at(ctx.time).hash(self.h); + Some(()) + } + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { return None; } + // A referenced 3D model or primitive layer renders through the comp's camera and lights + // (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what + // moves its pixels, so the owner keeps a cache entry that changes when any of it does. + if layer.source.is_model() { + self.soloed_model(ctx, layer)?; + return Some(()); + } self.remaining = self.remaining.checked_sub(1)?; - if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; } self.path.push(id); // Include transforms/parents as well as pixels: selected-layer anchors and // continuously rasterized sources can depend on them. hash_debug(self.h, layer); + // A referenced layer's static values can be overridden by the collapsed instance without + // editing it (`hash_values` only sees animated ones), so the owner's key needs them too. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); // A cached owner never runs this layer's effects, so the comp camera and lights its @@ -88,6 +148,10 @@ impl Dependencies<'_> { } match layer.source { LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let item = match layer.source { + LayerSource::Footage { .. } => ctx.media_item(layer, item), + _ => item, + }; let source = ctx.project.item(item); hash_debug(self.h, &source); if let Some(source) = source { diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index ca5660a67..4ea36d9b8 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -721,7 +721,7 @@ pub fn read_layer_entry(data: &[u8]) -> Option { /// The disk key of a segmentation: its chain key and scale. fn seg_key(key: u64, scale: f64) -> u128 { let mut h = Hash128::default(); - h.write(b"segmentation-v1"); + h.write(b"segmentation-v2"); h.write_u64(key); h.write_u64(scale.to_bits()); h.finish() @@ -841,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-2"; +const RENDER_VERSION: &str = "effectcraft-render-3"; fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); @@ -903,16 +903,26 @@ pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Opt /// Content key of a composition's pixels: project render settings, the comp and everything it /// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy /// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. +/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and +/// neither do markers unless an expression mentions them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { comp_content_key_with(project, comp, None) } /// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { + comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION) +} + +fn comp_content_key_for_renderer( + project: &effectcraft_project::Project, + comp: effectcraft_project::ItemId, + virtual_id: Option>, + renderer: &str, +) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); - h.write(RENDER_VERSION.as_bytes()); + h.write(renderer.as_bytes()); hash_debug(&mut h, &project.settings); let mut seen = std::collections::BTreeSet::new(); let mut stack = vec![comp]; @@ -933,13 +943,15 @@ pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effec } } } + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); h.write(it.name.as_bytes()); match &it.kind { - ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()), + ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)), k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { @@ -1070,6 +1082,28 @@ mod tests { assert!(dc.get_frame(42).is_none()); } + #[test] + fn previous_renderer_frames_are_not_reused() { + use effectcraft_project::{Comp, ItemKind, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut project = Project::default(); + let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0); + let dir = tmpdir("previous-renderer"); + { + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + cache.put_frame_now(previous_key, 2, 2, &[9; 16]); + } + let cache = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]); + assert!(cache.get_frame(key).is_none()); + cache.put_frame_now(key, 2, 2, &[17; 16]); + assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]); + } + #[test] fn persists_across_restarts_and_clears() { let dir = tmpdir("persist"); @@ -1244,7 +1278,7 @@ mod tests { let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)].into(); let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..81fe45d6c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -39,17 +39,33 @@ pub struct EvalCtx<'a> { /// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's /// own source while rendering; `None` renders footage as transparent. pub footage: Option<&'a dyn crate::FootageSource>, + /// Property values overridden by the Essential Properties of a collapsed precomp instance. + pub property_overrides: &'a [crate::PropertyOverride], + /// Media Replacement values overridden by the Essential Properties of a collapsed precomp + /// instance. + pub media_overrides: &'a [crate::MediaOverride], } impl<'a> EvalCtx<'a> { pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // A collapsed precomp instance's Essential Property override replaces the base value; + // its expression (if any) still runs, and sees the overrides of this context. + if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + let v = o.value(layer, prop, self.time); + if prop.has_expression() + && let Some(h) = self.expr + { + return h.eval(self, layer, prop, &v).unwrap_or(v); + } + return v; + } // Separated Position reads as the combination of X/Y/Z Position. if prop.match_id == "position" && let Some(tr) = layer.transform() @@ -60,7 +76,13 @@ impl<'a> EvalCtx<'a> { let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); return Value::Vec3([self.value(layer, px).as_f64(), y, z]); } - let lt = layer.layer_time(self.time); + self.value_at_layer_time(layer, prop, layer.layer_time(self.time)) + } + /// [`Self::value`] with the keyframes read at layer time `lt` exactly, rather than at the + /// context time mapped through the layer's start and stretch (which rounds under a + /// fractional stretch: a Hold key at `lt` could read its previous value). An expression + /// still runs at the context time. + pub fn value_at_layer_time(&self, layer: &Layer, prop: &Property, lt: Tick) -> Value { let v = prop.value_at(lt); if prop.has_expression() && let Some(h) = self.expr @@ -139,13 +161,15 @@ impl<'a> EvalCtx<'a> { /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps /// with another pixel aspect) is stretched horizontally by this factor in the comp. pub fn par_ratio(&self, layer: &Layer) -> f64 { - let par = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { - Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, - Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, - Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, - _ => 1.0, - }, + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item), + _ => None, + }; + let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, _ => 1.0, }; let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; @@ -231,9 +255,27 @@ impl<'a> EvalCtx<'a> { (Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0) } } + /// The footage item a layer uses, honouring this instance's Media Replacement override. + pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId { + self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item) + } + + /// Size of a layer's source in layer pixels, honouring Media Replacement. + pub fn source_size(&self, layer: &Layer) -> (u32, u32) { + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item), + _ => None, + }; + match layer.source { + LayerSource::Null => (100, 100), + _ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)), + } + } + /// [`effect_bounds`] of a layer of this comp. pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) { - effect_bounds(self.project, self.comp, layer) + effect_bounds_of(self.comp, self.source_size(layer)) } /// Effect space (see [`effect_bounds`]) → comp matrix. pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 { @@ -242,6 +284,20 @@ impl<'a> EvalCtx<'a> { } } +impl crate::PropertyOverride { + fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + if self.font { + // A Font control merges only the font into the target text, as + // `essential::with_overrides` does. + let mut value = prop.value_at(layer.layer_time(time)); + effectcraft_project::essential::merge_font(&mut value, &self.value); + value + } else { + self.value.clone() + } + } +} + /// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and /// clamped to the comp, in seconds. pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { @@ -315,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option /// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer /// space for effect points: a shape layer's default effect point is its bounds' centre). pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) { - match source_size(project, layer) { + effect_bounds_of(comp, source_size(project, layer)) +} + +fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) { + match size { (0, _) => { let (w, h) = (comp.width as f64, comp.height as f64); ([w, h], [-w / 2.0, -h / 2.0]) diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 04dd706db..db1561ad6 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,4 @@ -use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick}; +use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick}; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other @@ -93,6 +93,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 7dd0fc0de..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,7 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { @@ -132,7 +150,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]), LayerSource::Camera | LayerSource::Light { .. } => None, _ => { - let (w, h) = source_size(ctx.project, layer); + let (w, h) = ctx.source_size(layer); (w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64]) } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 8366cf7c0..65bafe002 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let sub = self.r.nested(); + // A 3D model or primitive layer has no 2D source of its own: an effect referencing one + // reads it as After Effects does — the layer rendered on its own through the comp's + // camera (Create 3D Layer Instance's effect), in the layer's effect space (the + // comp, centred on the layer's anchor). + if other.source.is_model() { + let buf = sub.solo_3d(self.ctx, other); + return Some(self.pixels(other, buf, false)); + } // The cached layer buffer is shared; effects get their own copy. let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; Some(self.pixels(other, buf, true)) @@ -90,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let item = self.ctx.media_item(other, *item); + let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None }; if !f.has_audio { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(*item, f, st, frames, rate) + self.r.footage.audio(item, f, st, frames, rate) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { @@ -118,6 +127,19 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) } + fn param_at(&self, effect_index: usize, id: &str, layer_time: f64) -> Option { + // Only the one property: a simulation samples it at every step. Its keyframes are read + // at the requested layer time exactly (mapping it to comp time and back rounds under a + // fractional stretch, so a Hold key there could read its previous value). + let p = effect_param(self.layer, effect_index, id)?; + let lt = Tick::from_seconds_f64(layer_time); + Some(self.ctx.at(self.layer.comp_time(lt)).value_at_layer_time(self.layer, p, lt)) + } + + fn param_is_static(&self, effect_index: usize, id: &str) -> bool { + effect_param(self.layer, effect_index, id).is_some_and(|p| p.keys.is_empty() && !p.has_expression()) + } + fn comp_scene(&self) -> Option { Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin)) } @@ -140,6 +162,26 @@ impl EffectHost for FxHost<'_, '_, '_> { } } +/// Parameter `id` (a flattened id: match ids joined by `/`) of effect `effect_index` in +/// `layer`'s stack. +fn effect_param<'a>(layer: &'a Layer, effect_index: usize, id: &str) -> Option<&'a effectcraft_project::Property> { + let mut g = layer.effects()?.groups().nth(effect_index)?; + let mut path = id.split('/').peekable(); + while let Some(m) = path.next() { + if path.peek().is_none() { + return g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p), + _ => None, + }); + } + g = g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg), + _ => None, + })?; + } + None +} + /// The Essential Properties overrides of a precomp layer, evaluated at the context time. pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec { use effectcraft_project::essential; diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index cac2add43..ce0a2dcde 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -2,7 +2,7 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; use super::{ Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, - MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2, }; /// Blend policy for placing a layer, including optional output-depth quantization. @@ -52,7 +52,7 @@ impl<'a> Renderer<'a> { } } let t0 = web_time::Instant::now(); - let size = source_size(self.project, layer); + let size = ctx.source_size(layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let st = styles::render(ctx, layer, &content, layer_size); if let Some(t) = timing { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0552a44d4..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,8 +48,8 @@ mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; pub use frames::{ - adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, - roi_inside, + adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, + render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; @@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; +pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index bb91ef71d..e6ba0a6fb 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; -use effectcraft_raster::{Mask, box_blur, gaussian_blur}; +use effectcraft_raster::{Mask, box_blur}; use rayon::prelude::*; use crate::eval::EvalCtx; @@ -60,40 +60,213 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu coverage_at(ctx, layer, g, buf) } +/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). +const MAX_FEATHER_PX: f64 = 1.0e6; +/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100, +/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask): +/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with +/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about +/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the +/// measured profile by 3/255 or more, this kernel by less than 1/255). +const SMOOTH_BOX_RADIUS: f64 = 0.37; +/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider +/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide +/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about +/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid +/// bounds the padded area, i.e. memory.) +const FULL_RES_RADIUS: usize = 48; +const COARSE_RADIUS: usize = 32; +/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened +/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the +/// buffer, is never dropped. +const MAX_GRID_PX: usize = 1 << 26; +/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer +/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel). +const MAX_COARSEN: usize = 1024; +/// Padding for a variable-width feather, which is rasterised at the buffer's resolution. +const MAX_VARIABLE_PAD: usize = 256; + +/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per +/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + step: usize, + pad: usize, + /// Radii of the box passes (Smooth: three; Linear: one, the others 0). + radii: [usize; 3], +} + +impl Axis { + /// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of + /// the path outside the buffer reaches into it as on an unbounded plane. + fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { + let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + let none = Axis { step: 1, pad: 0, radii: [0; 3] }; + if f <= 0.0 { + return none; + } + let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; + if linear { + // Linear falloff (not measured against After Effects): one box pass, a straight ramp + // `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider + // than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and + // resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp). + let r = (f / 2.0).round().max(1.0) as usize; + let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; + let rg = ((r as f64 / step as f64).round() as usize).max(1); + return Axis { step, pad: cap(rg), radii: [rg, 0, 0] }; + } + let r = (SMOOTH_BOX_RADIUS * f).round() as usize; + if r == 0 { + return none; + } + if full_res_only || r <= FULL_RES_RADIUS { + return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] }; + } + // On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to + // 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread + // the radii over the passes. + let want = 3 * (2 * r + 1); + let s0 = r.div_ceil(COARSE_RADIUS); + let fit = |s: usize| -> (usize, usize) { + let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize; + let got = (2 * sum + 3).saturating_mul(s); + (sum, got.abs_diff(want)) + }; + let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum)); + let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3)); + Axis { step, pad: sum + 1, radii } + } + + fn blurs(&self) -> bool { + self.radii.iter().any(|&r| r > 0) + } + + /// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring + /// axis keeps at least one grid pixel of radius), the padding still covering the support. + fn coarser(self, k: usize) -> Axis { + if k <= 1 { + return self; + } + let blurs = self.blurs(); + let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize); + if blurs && radii.iter().all(|&r| r == 0) { + radii[0] = 1; + } + let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1); + let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) }; + Axis { step: self.step.saturating_mul(k), pad, radii } + } + + /// Grid pixels for `n` buffer pixels. + fn len(&self, n: u32) -> Option { + (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) + } +} + +/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid +/// pixel centres (an exact copy when both steps are 1). +fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { + let (gw, gh) = (grid.width as usize, grid.height as usize); + let mut out = Mask::new(w, h, 0.0); + let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0); + // Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis. + let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) { + let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5; + let u = u.clamp(0.0, n.saturating_sub(1) as f64); + let i0 = u.floor() as usize; + (i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32) + }; + let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect(); + out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| { + let (y0, y1, fy) = map(y as u32, ay, gh); + for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) { + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + *o = top + (bottom - top) * fy; + } + }); + out +} + fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; - let path = effectcraft_path::to_kurbo(&sp); - let (w, h) = (buf.img.width, buf.img.height); let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale)); let exp = ctx.f(layer, g, "expansion", 0.0); let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + let feather = [feather[0] * buf.scale, feather[1] * buf.scale]; + Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX)) +} + +/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …) +/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even +/// the coarsest grid is too big). +fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> { + if !(ax.blurs() || ay.blurs()) { + return None; + } + let mut k = 1usize; + while k <= MAX_COARSEN { + let (cx, cy) = (ax.coarser(k), ay.coarser(k)); + if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h)) + && gw.checked_mul(gh).is_some_and(|n| n <= max_px) + && let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) + { + return Some((gw, gh, cx, cy)); + } + k = k.saturating_mul(2); + } + None +} + +/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp` +/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels. +fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask { + let path = effectcraft_path::to_kurbo(sp); // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); + // The feather blurs the mask's own edge, also where the path lies outside the layer (After + // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is + // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. + // A variable feather scales its point radii by the mean scale: it stays at full resolution + // (unless the grid has to be coarsened to fit `max_px`). + let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable)); + let blurred = ax.blurs() || ay.blurs(); + let grid = grid(w, h, ax, ay, max_px); + // Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step): + // blurred within the buffer at its resolution. + let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1]))); + // Buffer pixels -> grid pixels. + let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); + let mg = to_grid * *m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, 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) + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero) } else { - effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); - if feather[0] > 0.0 || feather[1] > 0.0 { - let img = cov.to_image(); - let b = if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); - box_blur(&img, r(feather[0]), r(feather[1]), 1, false) - } else { - gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false) - }; - cov = Mask::from_alpha(&b); + if blurred { + // Separable box passes (the corner of a feathered rect is the product of its edges). + let mut img = cov.to_image(); + for (rx, ry) in gx.radii.into_iter().zip(gy.radii) { + if rx > 0 || ry > 0 { + img = box_blur(&img, rx, ry, 1, false); + } + } + cov = Mask::from_alpha(&img); } - Some(cov) + if grid.is_some() { + cov = crop(&cov, gx, gy, w, h); + } + cov } /// The layer's enabled masks at the context time, flattened to polylines in layer space (for @@ -133,7 +306,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo if list.is_empty() { return false; } - // Masks may extend past the source: grow the buffer to cover them when feather/expansion pads. + // Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay + // empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside + // the buffer still reaches into it (`coverage_at`). let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; @@ -169,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo buf.img.mul_mask(&acc); true } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_keyframe::ShapePath; + + /// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded + /// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask + /// just outside the layer covered nothing and the whole layer vanished. The grid is now + /// coarsened instead and keeps a pad that covers the blur. + #[test] + fn a_grid_too_big_is_coarsened_and_keeps_its_padding() { + let a = Axis::new(40.0, false, false); + assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit"); + let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid"); + assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX); + for g in [gx, gy] { + assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}"); + // The pad covers the blur's support (the three boxes' radii) in buffer pixels. + assert!(g.pad * g.step >= g.radii.iter().sum::() * g.step, "{g:?}"); + } + // A grid that fits is not touched. + assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a)); + } + + /// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit + /// just below the full-resolution padded grid. The Add mask lies just right of the buffer + /// (x 210..260); its feather still fades into the layer, close to the full-resolution result. + #[test] + fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() { + let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0); + let m = Mat3::IDENTITY; + let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX); + let a = Axis::new(40.0, false, false); + let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1; + let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit); + let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0); + assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50)); + assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50)); + for x in [150, 170, 180, 190, 199] { + assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50)); + } + assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear"); + } +} diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index bc1879369..a4927144f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1,12 +1,14 @@ use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; -use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; +use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, }; +use effectcraft_keyframe::Value; +use effectcraft_project::LayerId; /// Coordinate space and stack range shared by CPU and accelerated effect evaluation. #[derive(Clone, Copy)] @@ -16,6 +18,88 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A property value an Essential Properties override gives a layer of a collapsed precomp. +#[derive(Clone, Debug)] +pub struct PropertyOverride { + pub(crate) layer: LayerId, + pub(crate) prop: u64, + pub(crate) value: Value, + /// A Font control changes only the font of the target text, not its text. + pub(crate) font: bool, +} + +/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed +/// precomp. +#[derive(Clone, Copy, Debug)] +pub struct MediaOverride { + pub(crate) layer: LayerId, + pub(crate) item: ItemId, +} + +impl MediaOverride { + /// A Media Replacement of `layer`'s footage by `item`. + pub fn new(layer: LayerId, item: ItemId) -> Self { + MediaOverride { layer, item } + } +} + +/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it. +pub struct Collapsed { + pub item: ItemId, + pub(crate) property_overrides: Vec, + pub(crate) media_overrides: Vec, +} + +/// The property and media overrides of a collapsed precomp layer. `None` when its Essential +/// Properties group or the source comp's definitions are missing (the flattened path then +/// preserves the old behaviour). +fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec, Vec)> { + use effectcraft_project::essential::{self, ControlType, EgKind}; + let over = essential::overridden(layer); + if over.is_empty() { + return Some((vec![], vec![])); + } + let g = essential::group(layer)?; + let eg = ctx.project.comp(item)?.essential.as_ref()?; + let mut property = vec![]; + let mut fonts: Vec = vec![]; + let mut media = vec![]; + g.walk("", &mut |_, p| { + if !over.contains(&p.uid) { + return; + } + let Some(control) = essential::control_of(&p.match_id) else { return }; + let Some(ctl) = eg.resolve(control) else { return }; + let value = ctx.value(layer, p); + match &ctl.kind { + EgKind::Property { .. } => { + let font = ctl.as_type == Some(ControlType::Font); + for (layer, prop) in ctl.kind.targets() { + let o = PropertyOverride { layer, prop, value: value.clone(), font }; + if font { fonts.push(o) } else { property.push(o) } + } + } + EgKind::Media { layer: target } => { + let Value::Scalar(id) = value else { return }; + let item = ItemId(id.max(0.0) as u64); + if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + media.push(MediaOverride { layer: *target, item }); + } + } + _ => {} + } + }); + // A Font control merges into the text its instance shows, after a whole-value (Text) override + // of the same property, as `essential::with_overrides` does: keep one entry per property. + for f in fonts { + match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) { + Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value), + None => property.push(f), + } + } + Some((property, media)) +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { @@ -122,8 +206,10 @@ impl<'a> Renderer<'a> { /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer - /// styles (those force a flattened render of the precomp, as in After Effects). - pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// styles (those force a flattened render of the precomp, as in After Effects). Essential + /// Properties overrides travel with it and are applied as the nested layers are evaluated, + /// so the nested comp is not rasterised at its frame. + pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { return None; } @@ -140,24 +226,37 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } - // Instance overrides (Essential Properties) need the nested comp rendered on its own. - if !effectcraft_project::essential::overridden(layer).is_empty() { + // A composition proxy or frame blending is drawn by the flattened path (`precomp_source`); + // collapsing renders the nested comp directly and would ignore both. + if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off { return None; } - Some(*item) + let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?; + Some(Collapsed { item: *item, property_overrides, media_overrides }) } /// A renderer and context that draw a collapsed precomp's layers straight into this comp: /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 3D layers use this comp's camera and lights. - pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)> + where + 'a: 'p, + { + let item = collapsed.item; let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { return None; } - let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; + let nctx = EvalCtx { + comp_id: item, + comp: nc, + time: t, + property_overrides: &collapsed.property_overrides, + media_overrides: &collapsed.media_overrides, + ..*ctx + }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -189,19 +288,19 @@ impl<'a> Renderer<'a> { /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` /// quantise their own footprints). - pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { + pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); @@ -230,6 +329,7 @@ impl<'a> Renderer<'a> { let continuous = match layer.source { LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { + let item = ctx.media_item(layer, item); layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } _ => false, @@ -267,7 +367,7 @@ impl<'a> Renderer<'a> { } pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) } + EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } } /// This renderer with another accelerator (or none): the passes of @@ -406,7 +506,7 @@ impl<'a> Renderer<'a> { blank = false; } else { let changed = match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank), None => self.draw_layer(ctx, visible[i], canvas, blank), }; i += 1; @@ -466,14 +566,26 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|s| s.skips_cover()) { + target.align(layer_size); + } else { + target.cover(layer_size); + } } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { @@ -594,8 +706,10 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - // A composition proxy (a rendered still or movie) stands in for the comp. - // Nothing before the nested comp starts or after it ends. + // A composition proxy (a rendered still or movie) stands in for the comp. It is + // decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear + // Light settings and the layer's Frame Blending apply. Nothing before the nested + // comp starts or after it ends. let lt = ctx.nested_time(layer); if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) { return None; @@ -603,7 +717,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?; + let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -635,7 +749,8 @@ impl<'a> Renderer<'a> { Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { - let it = self.project.item(*item)?; + let item = ctx.media_item(layer, *item); + let it = self.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; if !f.has_video { return None; @@ -643,7 +758,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(*item, f, k) { + if let Some(img) = self.footage.vector_frame(item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -653,10 +768,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(*item); + let proxy = self.proxy_for(item); let pf = proxy.unwrap_or(f); let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), @@ -714,16 +829,17 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -737,6 +853,14 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); + (f.alpha as u8).hash(&mut h); + if f.alpha == effectcraft_project::AlphaMode::Premultiplied { + f.premul_color.map(f32::to_bits).hash(&mut h); + } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -744,8 +868,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { @@ -755,7 +880,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. @@ -830,6 +955,30 @@ impl<'a> Renderer<'a> { self.source(ctx, layer) } + /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an + /// effect layer parameter reads when it references one, as After Effects, which renders 3D + /// layers through the active camera. The comp's pixels at this render's scale; the layer's + /// blend mode, track matte and video switch don't apply (a source read, not a composite — + /// the solo clone of a 3D track matte, see `three_d::adv`). + pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf { + let s = self.opts.scale; + let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1); + let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1); + let mut canvas = Image::new(w, h); + // Outside the layer's in/out points there is nothing to read: the copy is transparent + // there, like any other layer reference (the 3D pipelines don't gate on the range). + if !layer.is_active_at(ctx.time) { + return Buf { img: canvas, offset: [0.0; 2], scale: s }; + } + let mut solo = layer.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas); + Buf { img: canvas, offset: [0.0; 2], scale: s } + } + /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 511b32f3e..2497ea807 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag, -//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including -//! the paths of nested groups; +//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the +//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top; @@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; +use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor +/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its +/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly +/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above +/// the operation move on their own as far as Correlation allows, and the copies of a Repeater +/// below it move as one. The four transformations of one operation move together. +fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap, draws: &mut [Draw]) { + let Some(tr) = g.sub("transform") else { return }; + let w = Wiggly { + max: 1.0, + min: -1.0, + wiggles_per_second: ctx.f(layer, g, "speed", 2.0), + correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, g, "phase", 0.0), + spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0), + lock_dimensions: false, + seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64, + }; + let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]); + let pos = ctx.v2(layer, tr, "position", [0.0; 2]); + let scale = ctx.v2(layer, tr, "scale", [0.0; 2]); + let rot = ctx.f(layer, tr, "rotation", 0.0); + let t = layer.layer_time(ctx.time).seconds(); + let matrix = |k: usize| { + let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1)); + Mat3::translate(vec2(pos[0] * x, pos[1] * y)) + * Mat3::rotate_deg(rot * x) + * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0)) + * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y)) + }; + let mut by_copy: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + for &s in live { + if !done.insert(s) { + continue; + } + let k = copy_of.get(&s).copied().unwrap_or(0); + let m = *by_copy.entry(k).or_insert_with(|| matrix(k)); + arena[s] = mat_path(&arena[s], &m); + } + // A paint above the operation whose paths all moved by one matrix moves its gradient and + // stroke scale with them, as a group transform does. A paint over copies that moved by + // different matrices stays one paint (its fill rule and opacity apply to all of them at once) + // and keeps its gradient and stroke scale. + for d in draws { + let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten()); + let Some(Some(m)) = ms.next() else { continue }; + if ms.all(|x| x == Some(m)) { + d.xf = m * d.xf; + } + } +} + /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec) -> (Vec, Vec) { + collect_in(ctx, layer, contents, arena, &mut Copies::default()) +} + +/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater +/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's +/// groups, so copies made in a nested group stay apart in the groups around it. +#[derive(Default)] +struct Copies { + of: HashMap, + next: usize, +} + +fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, copy_ids: &mut Copies) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; @@ -201,7 +269,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let size = ctx.v2(layer, g, "size", [100.0; 2]); let pos = ctx.v2(layer, g, "position", [0.0; 2]); - let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)); + let p = if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [ + ctx.f(layer, g, "topLeftRoundness", 0.0), + ctx.f(layer, g, "topRightRoundness", 0.0), + ctx.f(layer, g, "bottomRightRoundness", 0.0), + ctx.f(layer, g, "bottomLeftRoundness", 0.0), + ], + ) + } else { + effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)) + }; push_shape(ctx, layer, g, p, arena, &mut live); } "ellipse" => { @@ -330,6 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec wiggle_transform(ctx, layer, g, arena, &live, ©_ids.of, &mut draws), "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); @@ -342,7 +424,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; - let (cd, cl) = collect(ctx, layer, inner, arena); + let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m); @@ -379,12 +461,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec> = Vec::with_capacity(n); + // Each copy of each earlier copy (a second Repeater) gets an id of its own. + let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1); + let base = copy_ids.of.get(&s).copied().unwrap_or(0); + let id = *ids.entry((i, base)).or_insert_with(|| { + copy_ids.next += 1; + copy_ids.next + }); + copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map); @@ -396,7 +486,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; + // Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per + // copy, so the last copy stops one step short of End Opacity (After Effects + // 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %). + let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 }; let opk = so + (eo - so) * t; let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 }; for d in &base_draws { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 2ca03b4f6..ba7028a25 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() { assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img)); } +/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies +/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 % +/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows +/// only the last copy, only the middle one, and both. +#[test] +fn repeater_copy_opacity_matches_after_effects() { + let (p, cid) = shape_comp_sized(1920, 1080, |ids| { + let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]); + set(&mut rep, "transform/rotation", Value::Scalar(10.0)); + set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0])); + set(&mut rep, "transform/endOpacity", Value::Scalar(40.0)); + let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep]; + vec![build::shape_group(ids, "Group 1", items)] + }); + let img = frame(&p, cid); + // AE's green over its red layer, 153, 153, 204, 235 and 255 of 255. + for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] { + let a = alpha(&img, x, y); + assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}"); + } +} + #[test] fn repeater_then_merge_unites_copies() { let (p, cid) = shape_comp(|ids| { @@ -288,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`. +fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] { + let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height as i64 { + let a = alpha(img, x, y); + sx += a * x as f32; + sy += a * y as f32; + sa += a; + } + } + [sx / sa, sy / sa] +} + +fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup { + op(ids, "wiggler", params) +} + +#[test] +fn wiggle_transform_moves_paths_over_time() { + let run = |position: [f64; 2], t: f64| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]); + vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0) + }; + // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5. + let rest = [99.5, 49.5]; + assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest); + let a = run([15.0, 15.0], 0.37); + assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame"); + let b = run([15.0, 15.0], 1.21); + let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200)); + assert!(ca != rest && ca != cb, "{ca:?} {cb:?}"); + for c in [ca, cb] { + assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}"); + } + // A translation keeps the square's area. + assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a)); + // The layer cache keys a layer with a Wiggle Transform by time. + let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]); + let comp = p.comp(cid).unwrap(); + let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false); + assert_ne!(key(0.37), key(1.21)); +} + +#[test] +fn wiggle_transform_scales_and_rotates() { + let run = |params: &[(&str, Value)]| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, params); + vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0) + }; + let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))])); + assert!((scaled - 1600.0).abs() > 20.0, "{scaled}"); + let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]); + assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated)); + assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away"); +} + +/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform. +#[test] +fn wiggle_transform_moves_the_gradient_with_the_path() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), + build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g), + wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let cols: Vec = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect(); + let (l, r) = (cols[0], cols[cols.len() - 1]); + assert!((l - 80).abs() > 3, "the square moved: {l}"); + let (cl, cr) = (img.get(l, 50), img.get(r, 50)); + assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}"); +} + +/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike +/// (Correlation 100). +#[test] +fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let rows: Vec = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect(); + let (t, b) = (rows[0], rows[rows.len() - 1]); + assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}"); + let (ct, cb) = (img.get(cx, t), img.get(cx, b)); + assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}"); + } +} + +/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's +/// middle at the group's origin row. +#[test] +fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let c = img.get(cx, 50); + assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}"); + } +} + +/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill +/// covers them once. +#[test] +fn wiggle_transform_keeps_one_fill_over_overlapping_copies() { + let (p, cid) = shape_comp(|ids| { + let mut fill = build::shape_fill(ids, RED); + set(&mut fill, "opacity", Value::Scalar(50.0)); + vec![ + build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [20.0, 0.0]), + fill, + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max); + assert!((top - 0.5).abs() < 0.01, "{top}"); +} + +/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies. +#[test] +fn long_repeater_stack_with_cleared_copies_renders() { + let (p, cid) = shape_comp(|ids| { + let mut items = vec![]; + for _ in 0..70 { + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0])); + items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0])); + } + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0])); + items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])); + items.push(build::shape_fill(ids, RED)); + items + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img)); +} + +/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one +/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same. +#[test] +fn wiggle_transform_and_repeater_order() { + let offsets = |wiggle_first: bool, correlation: f64| { + let (p, cid) = shape_comp(|ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0); + let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]); + let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]); + let fill = build::shape_fill(ids, RED); + if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] } + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0) + }; + let own = offsets(false, 0.0); + assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}"); + // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own. + let (p, cid) = shape_comp(|ids| { + let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)]; + let group = build::shape_group(ids, "Repeated", inner); + vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0); + assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}"); + for same in [offsets(false, 100.0), offsets(true, 0.0)] { + assert!(same[0].abs() > 0.5, "{same:?}"); + assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}"); + } +} + #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| { @@ -497,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() { let b = shape_buf(&p, cid); assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height); } + +#[test] +fn rect_per_corner_roundness_rounds_the_named_corners() { + let render = |per_corner: bool, radii: [f64; 4], roundness: f64| { + let (p, cid) = shape_comp_sized(200, 100, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + vec![g, build::shape_fill(ids, RED)] + }); + frame(&p, cid) + }; + // Only the top-left corner rounds: its corner pixels fade, the other three stay filled. + let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0); + assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2)); + for (x, y) in [(148, 2), (148, 98), (52, 98)] { + assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y)); + } + // The checkbox off ignores the four values (the single Roundness still rules). + let off = render(false, [30.0; 4], 0.0); + assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off)); + // Four equal per-corner radii render like the same Roundness. + let per = render(true, [25.0; 4], 0.0); + let uni = render(false, [0.0; 4], 25.0); + assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni)); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 079274499..488f38ec7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -16,6 +16,7 @@ mod frame_blend; mod guides; mod mask_blur; mod mask_expansion; +mod mask_feather; mod motion_tile; mod nested; mod paint; @@ -313,7 +314,7 @@ fn particle_world_scene(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, l]; + p.comp_mut(cid).unwrap().layers = vec![cam, l].into(); (p, cid) } @@ -346,6 +347,208 @@ fn particle_world_follows_the_comp_camera() { assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}"); } +/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a +/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. +fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { + burst_scene_keys(cut, start, stretch, false) +} + +/// [`burst_scene`]; `hold`: Hold keys, 3 until `cut` and 0 from `cut` exactly. +fn burst_scene_keys(cut: Option, start: f64, stretch: f64, hold: bool) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))]; + add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals); + if let Some(cut) = cut { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let fx = fx.unwrap(); + let rate = fx.prop_mut("birthRate").unwrap(); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + rate.keys = if hold { vec![key(0.0, 3.0).hold(), key(cut, 0.0).hold()] } else { vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)] }; + } + l.start_time = Tick::from_seconds_f64(start); + l.in_point = l.start_time; + l.out_point = Tick::from_seconds_f64(4.0); + l.stretch = stretch; + p.comp_mut(cid).unwrap().layers = vec![l].into(); + (p, cid) +} + +/// Total light in a frame. +fn light(img: &crate::Image) -> f32 { + img.data.iter().map(|p| p[0] + p[1] + p[2]).sum() +} + +/// #1373: a Birth Rate keyed down to 0 stops new births; the particles already born live on +/// until their Longevity runs out (they vanished the frame the rate reached 0). +#[test] +fn particle_world_keeps_a_burst_after_birth_rate_drops_to_zero() { + let (keyed, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (constant, _) = burst_scene(None, 0.0, 100.0); + let at = Tick::from_seconds_f64; + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), "during the burst"); + let (k, c) = (light(&render_cached(&keyed, cid, at(0.9), None)), light(&render_cached(&constant, cid, at(0.9), None))); + assert!(k > 1.0 && k < c, "the burst is still drawn, with no new births: {k} vs {c}"); + // Longevity 1 s (± 20 %): gone by 1.9 s. + assert_eq!(light(&render_cached(&keyed, cid, at(1.9), None)), 0.0); +} + +/// The burst's history: moving an earlier key changes a later frame whose Birth Rate is the +/// same (0), cached or not, and offset or stretched layers sample their own layer times. +#[test] +fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() { + let at = Tick::from_seconds_f64; + let (long, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (short, _) = burst_scene(Some(0.25), 0.0, 100.0); + let cache = crate::LayerCache::default(); + let a = render_cached(&long, cid, at(0.9), Some(&cache)); + let b = render_cached(&short, cid, at(0.9), Some(&cache)); + assert_same(&b, &render_cached(&short, cid, at(0.9), None), "cached render after an earlier key moved"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "a shorter burst: {} vs {}", light(&b), light(&a)); + let plain = render_cached(&long, cid, at(0.9), None); + let (late, _) = burst_scene(Some(0.5), 0.5, 100.0); + assert_same(&render_cached(&late, cid, at(1.4), None), &plain, "a layer starting at 0.5 s"); + let (slow, _) = burst_scene(Some(0.5), 0.0, 200.0); + assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %"); +} + +/// The Birth Rate history is read at exact layer times: under a fractional stretch, mapping a +/// layer time to comp time and back rounds down a tick, so a Hold cut at 0.5 s read its +/// previous value at the step starting there (six extra births). +#[test] +fn particle_world_reads_a_hold_cut_at_its_exact_layer_time_under_any_stretch() { + use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer}; + let plain = { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, 100.0, true); + render_cached(&p, cid, Tick::from_seconds_f64(0.9), None) + }; + assert!(light(&plain) > 1.0, "the burst is drawn"); + for stretch in [33.3, 133.33, 200.0] { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, stretch, true); + let c = p.comp(cid).unwrap(); + let l = &c.layers[0]; + let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); + let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let host = FxHost { r: &renderer, ctx: &ctx, layer: l, origin: [0.0; 2], index: Default::default(), original: None }; + let rate = |t: f64| host.param_at(0, "birthRate", t).unwrap().as_f64(); + assert_eq!((rate(0.5 - 1.0 / 60.0), rate(0.5)), (3.0, 0.0), "stretch {stretch} %"); + assert!(!host.param_is_static(0, "birthRate")); + let t = l.comp_time(Tick::from_seconds_f64(0.9)); + assert_same(&render_cached(&p, cid, t, None), &plain, &format!("stretch {stretch} %")); + } +} + +/// An expression that reads a Controller layer's Opacity (÷ 25) before layer time 0.5 s and is +/// 0 after (a stand-in for the expression engine). +struct ControllerRate; + +impl crate::eval::ExprHost for ControllerRate { + fn eval(&self, ctx: &crate::eval::EvalCtx, layer: &effectcraft_project::Layer, _: &effectcraft_project::Property, _: &Value) -> Result { + if layer.layer_time(ctx.time).seconds() >= 0.5 { + return Ok(Value::Scalar(0.0)); + } + let c = ctx.comp.layers.iter().find(|l| l.name == "Controller").ok_or("no controller")?; + let opacity = c.props.prop("transform/opacity").ok_or("no opacity")?; + Ok(Value::Scalar(ctx.value(c, opacity).as_f64() / 25.0)) + } +} + +/// A Birth Rate expression reading another layer: changing that layer changes the history but +/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. +/// CC Particle World layers with expressions therefore aren't layer cached. +#[test] +fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() { + let scene = |opacity: f64| { + let (mut p, cid) = burst_scene(None, 0.0, 100.0); + let comp = p.comp(cid).unwrap().clone(); + let mut ctl = solid(&mut p, &comp, [0.0, 0.0, 0.0], 10, 10); + ctl.name = "Controller".into(); + ctl.switches.video = false; + ctl.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity); + let c = p.comp_mut(cid).unwrap(); + let fx = c.layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "controller".into(), enabled: true }); + c.layers.push(ctl); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.expr = Some(&ControllerRate); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(0.9)) + }; + let ((many, cid), (few, _)) = (scene(75.0), scene(25.0)); + let cache = crate::LayerCache::default(); + let a = render(&many, cid, Some(&cache)); + let b = render(&few, cid, Some(&cache)); + assert_same(&b, &render(&few, cid, None), "cached render after the controller changed"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); +} + +/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems +/// II, CC Mr. Mercury): Birth Rate `rate`, +/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s. +fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); + let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))]; + add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals); + if rate.is_none() { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)]; + } + p.comp_mut(cid).unwrap().layers = vec![l].into(); + (p, cid) +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst +/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate +/// of 0 throughout), and draws as nothing born once Longevity has run out. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() { + let at = Tick::from_seconds_f64; + for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (keyed, cid) = system_scene(id, None); + let (constant, _) = system_scene(id, Some(3.0)); + let (none, _) = system_scene(id, Some(0.0)); + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst")); + let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None)); + assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z)); + assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity")); + } +} + +/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the +/// layer cache (its key can't see what the expression returned at earlier times); without one +/// the layer is cached. +#[test] +fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() { + for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (mut p, cid) = system_scene(id, Some(2.0)); + let key = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false) + }; + assert!(key(&p).is_some(), "{id}: cached without an expression"); + let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true }); + assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression"); + } +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { @@ -411,7 +614,7 @@ fn cache_scene() -> (Project, ItemId) { vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![text, shape, bg]; + c.layers = vec![text, shape, bg].into(); (p, cid) } @@ -651,7 +854,7 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each. let mut q = [0.0f32; 4]; @@ -685,7 +888,7 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each. let mut q = [0.0f32; 4]; @@ -714,7 +917,7 @@ fn effects_on_text_reach_past_the_text_bounds() { let plain = render_frame(&p, cid, Tick::ZERO, 1.0); let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); let (mut lo, mut hi) = (u32::MAX, 0u32); for y in 0..100 { @@ -748,7 +951,7 @@ fn cc_smear_trails_text_past_its_bounds() { "ec.distort.ccsmear", &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } @@ -771,7 +974,7 @@ fn layer_parameters_see_a_whole_shape_layer() { let matte_id = shape.id; let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]); - p.comp_mut(cid).unwrap().layers = vec![s, shape]; + p.comp_mut(cid).unwrap().layers = vec![s, shape].into(); let f = render_frame(&p, cid, Tick::ZERO, 1.0); // The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside. for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] { @@ -861,7 +1064,7 @@ fn self_inputs_share_source_semantics_across_effects_and_times() { "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let c = p.comp(cid).unwrap(); let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some()); @@ -879,7 +1082,7 @@ fn timed_source_reads_preserve_other_layer_masks() { let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); p.next_id = next; other.props.sub_mut("masks").unwrap().children.push(mask.into()); - p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()]; + p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into(); let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None }; @@ -900,7 +1103,7 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() { let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated]; + p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into(); let key = |p: &Project, time: f64, limit| { let comp = p.comp(cid).unwrap(); let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time)); @@ -941,12 +1144,12 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))]; let mut nested = comp.clone(); - nested.layers = vec![child]; + nested.layers = vec![child].into(); let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into())); let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None); let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, pre]; + p.comp_mut(cid).unwrap().layers = vec![owner, pre].into(); let key = |p: &Project, time| { let c = p.comp(cid).unwrap(); crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap() @@ -1021,7 +1224,7 @@ fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, owner, source]; + p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into(); (p, cid) } @@ -1073,3 +1276,194 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() { assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); assert_eq!(cache.stats().entries, 1); } + +// ------------------------------------------------------------------------------ 3D layer instances + +/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a +/// data URI), parsed in memory: a FootageSource that knows one model item. +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +struct SoloModels; + +impl crate::FootageSource for SoloModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the +/// copy: a solid with a 3D Layer Instance effect referencing the model. +fn instance_scene(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + // Models are Advanced 3D (layer.newModel switches a Classic comp over). + p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + let mid = p.add_item( + "Triangle", + Label::Red, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "Triangle.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }), + ); + let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model].into(); + (p, cid) +} + +fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not. +fn cover_x(img: &crate::Image) -> f32 { + let (mut m, mut sx) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let a = img.get(x as i64, y as i64)[3]; + m += a; + sx += a * x as f32; + } + } + assert!(m > 1.0, "the triangle is drawn"); + sx / m +} + +/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the +/// referenced model's solo render depends on — the camera, and the model's in/out gate. +#[test] +fn an_instance_of_a_model_is_cached_and_follows_the_camera() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let (q, _) = instance_scene(40.0); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key"); + // The same layer at a later time, outside its in/out range: the read turns transparent. + let mut out = p.clone(); + out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5); + assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key"); + // Two renders of one time: the second is served from the cache. + let cache = crate::LayerCache::default(); + let here = render_models(&p, cid, t, Some(&cache)); + let again = render_models(&p, cid, t, Some(&cache)); + assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one"); + assert!(cache.stats().hits > 0, "the copy was served from the cache"); + // Panning the camera: the copy (and the model) move with it. + let panned = render_models(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (cover_x(&here), cover_x(&panned)); + assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); +} + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +} diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..a6c85f2de 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -78,7 +78,7 @@ fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, l.props.sub_mut("effects").unwrap().children.push(g.into()); } let mut comp = comp; - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -148,6 +148,42 @@ fn stereo_mixer_pans() { assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]); } +/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s. +fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) { + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() }; + g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)]; +} + +/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples. +fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec { + fn half(_: f64) -> (f32, f32) { + (0.5, 0.5) + } + let mut m = vec![]; + for k in 0..SR as usize / block { + m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block)); + } + m.iter().step_by(2).copied().collect() +} + +/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one +/// second at a time), instead of holding the value at the block's first sample. +#[test] +fn animated_levels_ramp_within_a_block() { + let (mut levels, lc) = comp_with(None, &[], false); + key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0])); + let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false); + key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0)); + for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] { + let one = left_ramp(p, cid, SR as usize); + assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic"); + assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]); + assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]); + assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result"); + } +} + #[test] fn tone_makes_a_solid_audible_at_its_frequency() { let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))]; @@ -236,7 +272,7 @@ fn audio_spectrum_follows_time_through_the_layer_cache() { g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); s.props.sub_mut("effects").unwrap().children.push(g.into()); let mut comp = comp; - comp.layers = vec![s, a]; + comp.layers = vec![s, a].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = Signal(late_tone); diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..30d2163c1 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -3,7 +3,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -53,17 +53,17 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { a.parent = Some(null.id); b.parent = Some(null.id); let mut c = outer.clone(); - c.layers = vec![null, b, a, bg]; + c.layers = vec![null, b, a, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) } else { let mut inner = inner; - inner.layers = vec![b, a]; + inner.layers = vec![b, a].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); xf(&mut pre); pre.switches.collapse = collapse; let mut c = outer.clone(); - c.layers = vec![pre, bg]; + c.layers = vec![pre, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) }; (p, cid) @@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() { assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); } +/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer +/// renders the nested comp directly and ignores both, so such a layer must not collapse. +#[test] +fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() { + let (mut p, cid) = scene(true, false); + let id = p.comp(cid).unwrap().layers[0].id; + let collapses = |p: &Project| { + let r = Renderer::new(p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO); + r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some() + }; + assert!(collapses(&p), "a plain precomp collapses"); + // Frame blending needs both the comp's switch and the layer's. + p.comp_mut(cid).unwrap().enable_frame_blending = true; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix; + assert!(!collapses(&p), "frame blending keeps the flattened path"); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + // A composition proxy stands in for the precomp's pixels. + let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") }; + let proxy = + effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true }; + p.item_mut(item).unwrap().proxy = Some(Box::new(proxy)); + assert!(!collapses(&p), "a proxy keeps the flattened path"); +} + +/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed +/// instances give A different static overrides (`hash_values` only sees animated values). +#[test] +fn referenced_layer_overrides_change_the_owner_cache_key() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]); + let aid = a.id; + let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]); + let bid = b.id; + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]); + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0)); + p.next_id = next; + b.props.sub_mut("effects").unwrap().children.push(fx.into()); + let mut c = comp; + c.layers = vec![b, a].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let comp = p.comp(cid).unwrap(); + let b = comp.layer(bid).unwrap(); + let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = |value: f64| { + let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; + let ctx = + EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + crate::cache::layer_key(&ctx, b, 1.0, false, false) + }; + let (opaque, half) = (key(100.0), key(50.0)); + assert!(opaque.is_some(), "the owner is cacheable"); + assert_ne!(opaque, half, "A's static override changes B's key"); +} + #[test] fn collapsed_3d_layers_join_the_parent_scene() { // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a @@ -122,7 +182,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None); pre.switches.three_d = true; @@ -130,7 +190,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]); red.switches.three_d = true; let mut c = outer; - c.layers = vec![red, pre]; + c.layers = vec![red, pre].into(); let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) }; @@ -168,11 +228,11 @@ fn scaled_square(cr: bool, via_parent: bool) -> Image { set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); l.parent = Some(null.id); - c.layers = vec![l, null]; + c.layers = vec![l, null].into(); } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - c.layers = vec![l]; + c.layers = vec![l].into(); } let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) @@ -201,7 +261,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]); l.switches.quality = Quality::Wireframe; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert_eq!(img.get(50, 50)[3], 0.0, "no fill"); @@ -212,7 +272,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]); l.switches.quality = Quality::Draft; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); @@ -230,13 +290,13 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); pre.switches.three_d = three_d_precomp; pre.switches.collapse = true; let mut c = outer.clone(); - c.layers = vec![pre]; + c.layers = vec![pre].into(); if camera { let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); c.layers.insert(0, cam); @@ -267,7 +327,7 @@ fn text_scaled_up_stays_sharp_without_the_switch() { set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); assert!(!l.switches.collapse); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; @@ -291,7 +351,7 @@ fn unscaled_text_rasterises_at_the_output_scale() { let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() })); - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); for i in 0..500 { let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0]; @@ -327,7 +387,7 @@ fn effects_on_a_scaled_shape_follow_its_scale() { set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; diff --git a/crates/render/src/tests/guides.rs b/crates/render/src/tests/guides.rs index cb9851250..44f3fc7b0 100644 --- a/crates/render/src/tests/guides.rs +++ b/crates/render/src/tests/guides.rs @@ -40,7 +40,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { 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]; + inner.layers = vec![guide, bg].into(); 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) { @@ -49,7 +49,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { 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]; + outer.layers = vec![pre].into(); let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); (p, iid, oid) } diff --git a/crates/render/src/tests/mask_feather.rs b/crates/render/src/tests/mask_feather.rs new file mode 100644 index 000000000..db04b1f9f --- /dev/null +++ b/crates/render/src/tests/mask_feather.rs @@ -0,0 +1,251 @@ +//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather +//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the +//! coverage stopping at the layer edge. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) +} + +fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) { + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]); + p.next_id = next; + m.get_mut("feather").unwrap().value = Value::Vec2(feather); + if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind { + *feather_falloff = FeatherFalloff::Linear; + } + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); +} + +/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide +/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w). +fn edge(d: f64, w: f64) -> f64 { + let t = (d / w + 1.5).clamp(0.0, 3.0); + if t < 1.0 { + t * t * t / 6.0 + } else if t < 2.0 { + (-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0 + } else { + 1.0 - (3.0 - t).powi(3) / 6.0 + } +} + +/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`. +fn window(feather: f64) -> f64 { + 2.0 * (0.37 * feather).round() + 1.0 +} + +/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis). +fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 { + let w = window(feather); + edge(x - a, w) + edge(b - x, w) - 1.0 +} + +/// A white 200×100 solid (32 bpc) with one Add mask. +fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100); + add_mask(&mut p, &mut l, rect, feather, linear); + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, scale) +} + +#[test] +fn a_feathered_mask_outside_a_solid_reaches_into_it() { + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes. + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); + for x in [150i64, 170, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0); + let got = img.get(x, 50)[3] as f64; + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}"); + } + assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); + // Finite support: nothing beyond 1.5 windows (46.5 px) from the edge. + assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0); + // Without feather the mask hides the layer. + let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); + assert!(hard.data.iter().all(|p| p[3] == 0.0)); + // A linear falloff ramps over the feather width: 20 px either side of the edge at x 210. + let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0); + let a = lin.get(199, 50)[3]; + assert!((a - 0.24).abs() < 0.04, "linear: {a}"); + assert!(lin.get(185, 50)[3] < 0.01); + // At half resolution the same layer pixel gets the same coverage. + let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5); + assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]); +} + +#[test] +fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() { + // The mask covers the layer and 100 px around it: the layer edges are inside the mask and + // stay opaque (they used to fade, as if the layer edge were the mask edge). + let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0); + for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] { + assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]); + } + // A mask edge inside the layer is feathered as before: half coverage on the edge. + let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0); + assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]); + assert!(inner.get(100, 50)[3] > 0.98); +} + +#[test] +fn huge_and_broken_feathers_stay_bounded() { + for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] { + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}"); + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}"); + } +} + +/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a +/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and +/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000. +fn case_398(with_mask: bool) -> (Project, ItemId) { + let mut p = Project::default(); + assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth"); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080); + adj.switches.adjustment = true; + let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap(); + let mut next = p.next_id; + let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + adj.props.sub_mut("effects").unwrap().children.push(ramp.into()); + if with_mask { + add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false); + } + let c = p.comp_mut(cid).unwrap(); + c.layers.push(adj); + c.layers.push(red); + (p, cid) +} + +fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] { + let px = img.get(x, y); + [0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32) +} + +#[test] +fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = case_398(true); + let img = render_frame(&p, cid, t, 1.0); + // The ramp alone (no mask): what the adjustment layer mixes in. + let (q, qid) = case_398(false); + let ramp = render_frame(&q, qid, t, 1.0); + // After Effects 2026, 8 bpc, the render of 398 at 1 s. + let ae = [ + ((960, 0), [255, 0, 0]), + ((960, 300), [254, 0, 0]), + ((960, 540), [250, 5, 5]), + ((960, 800), [248, 20, 20]), + ((960, 1000), [252, 40, 40]), + ((960, 1079), [255, 47, 47]), + ((100, 1079), [255, 29, 29]), + ]; + let mut report = Vec::new(); + for ((x, y), want_ae) in ae { + let got = rgb8(&img, x, y); + // 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000), + // blurred from the mask edge outside the layer (on the coarse grid); the result is the + // red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0); + let r = ramp.get(x, y); + let want = [0, 1, 2].map(|k| { + let red = if k == 0 { 1.0 } else { 0.0 }; + ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 + }); + for k in 0..3 { + assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}"); + } + // 2. After Effects: within 2 levels per channel. + for k in 0..3 { + assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + } + report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); + } + println!("398: {}", report.join("; ")); + // The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red. + assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); + assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); +} + +/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at +/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from +/// x 160 to 760 in steps of 20 (8-bit values / 255). +const AE_FEATHER_100: [(i64, f64); 11] = + [(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)]; +const AE_FEATHER_300: [(i64, f64); 31] = [ + (160, 0.0), + (180, 0.004), + (200, 0.008), + (220, 0.012), + (240, 0.024), + (260, 0.035), + (280, 0.055), + (300, 0.078), + (320, 0.11), + (340, 0.149), + (360, 0.192), + (380, 0.247), + (400, 0.306), + (420, 0.369), + (440, 0.435), + (460, 0.502), + (480, 0.569), + (500, 0.635), + (520, 0.698), + (540, 0.757), + (560, 0.808), + (580, 0.851), + (600, 0.89), + (620, 0.922), + (640, 0.945), + (660, 0.965), + (680, 0.976), + (700, 0.988), + (720, 0.992), + (740, 0.996), + (760, 1.0), +]; + +#[test] +fn the_smooth_feather_falloff_matches_after_effects() { + for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let mut worst = 0.0f64; + for &(x, want) in ae { + let got = img.get(x, 540)[3] as f64; + // Tolerance 2/255 on the 8-bit After Effects values. + assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}"); + worst = worst.max((got - want).abs()); + } + // The corner is the product of its two edges (a separable blur): 0.25 for both feathers. + let corner = img.get(460, 240)[3] as f64; + assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}"); + println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0); + } +} diff --git a/crates/render/src/tests/motion_tile.rs b/crates/render/src/tests/motion_tile.rs index 7d1645a02..28faca73c 100644 --- a/crates/render/src/tests/motion_tile.rs +++ b/crates/render/src/tests/motion_tile.rs @@ -58,7 +58,7 @@ fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { tile_then_scale(&mut p, &mut adj, out, scale); let mut layers = vec![adj]; layers.extend(picture(&mut p, &comp)); - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } @@ -68,7 +68,7 @@ fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); let layers = picture(&mut p, &inner); - p.comp_mut(iid).unwrap().layers = layers; + p.comp_mut(iid).unwrap().layers = layers.into(); let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); tile_then_scale(&mut p, &mut pre, out, scale); p.comp_mut(cid).unwrap().layers.push(pre); @@ -151,7 +151,7 @@ fn adjustment_motion_tile_matches_the_after_effects_frames() { } adj.props.sub_mut("effects").unwrap().children.push(g.into()); } - p.comp_mut(cid).unwrap().layers = vec![adj, red]; + p.comp_mut(cid).unwrap().layers = vec![adj, red].into(); // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). let img = render_frame(&p, cid, Tick::ZERO, 0.25); let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); diff --git a/crates/render/src/tests/roi.rs b/crates/render/src/tests/roi.rs index 9182817cf..a9892d9c7 100644 --- a/crates/render/src/tests/roi.rs +++ b/crates/render/src/tests/roi.rs @@ -29,7 +29,7 @@ fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { } layers.push(l); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index b59f009de..2450fcf64 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -44,7 +44,7 @@ fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Co if let Some(bg) = bg { layers.push(solid(&mut p, &comp, bg, 200, 100)); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 8739140de..e9599f70b 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() { } let key = |p: &Project| { let comp = p.comp(cid).unwrap(); - let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(0.5), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) }; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index e1954b597..19c17d53f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -509,6 +510,7 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + pub join: effectcraft_path::Join, /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows /// on every side, in character space (0 when not bold). pub bold: f64, @@ -694,10 +696,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. + pen += x.track_before; shift[gi] = pen; - pen += x.track_after * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; @@ -805,9 +809,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); @@ -832,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + join: path_join(doc.line_join), bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, @@ -847,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> { text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default() } +fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join { + use effectcraft_keyframe::LineJoin; + match j { + LineJoin::Miter => effectcraft_path::Join::Miter, + LineJoin::Round => effectcraft_path::Join::Round, + LineJoin::Bevel => effectcraft_path::Join::Bevel, + } +} + /// One paint pass of a glyph: fill (false) or stroke (true). type Pass = (usize, bool); @@ -913,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: } outer } else if stroke { - let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { grown_coverage(paths, &mm, w, h, bold) diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index ea89e37b0..503ea097c 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -220,9 +220,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -262,10 +262,10 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; - let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -277,11 +277,36 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } +/// #1798: After Effects adds Tracking Amount in absolute pixels between neighbouring characters, +/// independent of font size. +#[test] +fn text_animator_tracking_amount_uses_pixels_independent_of_font_size() { + let width_diff = |font_size: f64, amount: f64| { + let (mut p, cid, comp) = setup(2000, 400); + let mut l = text_layer(&mut p, &comp, doc("ABCDE", font_size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let w = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])).width(); + let (mut p0, cid0, comp0) = setup(2000, 400); + let l0 = text_layer(&mut p0, &comp0, doc("ABCDE", font_size)); + p0.comp_mut(cid0).unwrap().layers.push(l0); + let w0 = bounds_of(&glyph_paths(&ctx(&p0, cid0, 0.0), &p0.comp(cid0).unwrap().layers[0])).width(); + w - w0 + }; + // 5 characters = 4 gaps. In After Effects, Tracking Amount 100 adds 100 px per gap = 400 px total width delta, + // regardless of whether font size is 50 px or 200 px. + let diff_50 = width_diff(50.0, 100.0); + let diff_200 = width_diff(200.0, 100.0); + assert!((diff_50 - 400.0).abs() < 1.0, "font_size 50 px width delta: {diff_50}"); + assert!((diff_200 - 400.0).abs() < 1.0, "font_size 200 px width delta: {diff_200}"); +} + #[test] fn anchor_grouping_line_rotates_around_line_centre() { let (mut p, cid, comp) = setup(600, 300); @@ -479,6 +504,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); @@ -542,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 0.0, apply_fill: true, char_index: 0, @@ -612,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() { fill: [1.0; 4], stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 2.0, apply_fill: true, char_index: 0, diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 395057935..1c37a6267 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -13,7 +13,7 @@ use std::collections::BinaryHeap; use rayon::prelude::*; -use super::scene::{Scene, ShadowMap, Vertex}; +use super::scene::{EnvInfo, Scene, ShadowMap, Vertex}; use super::shade::{Frag, sample, shade}; /// A rendered target at raster size. @@ -500,11 +500,39 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } -/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). +/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere +/// from its dominant direction (the light travels from the bright region into the scene), like +/// a parallel light's. +fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap { + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); + let sun = e.sun.map(|v| v as f64); + let dir = [-sun[0], -sun[1], -sun[2]]; + let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; + let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; + let focal = (size as f64 * 0.5 / radius) as f32; + ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 } +} + +/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of +/// the Environment light when it casts shadows and its image has a dominant direction (marked +/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) .filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows)) .collect(); + // The Environment light casts a shadow when marked and its image has a dominant direction; + // with nothing to cast one, its mark is cleared either way. + let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]); + if !env_shadow && let Some(e) = &mut s.env { + e.shadow = -1; + } if casters.is_empty() { for l in &mut s.lights { l.shadow = -1; @@ -536,6 +564,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { 1 } }) + // The Environment light's map is a single orthographic one. + .chain(std::iter::once(if env_shadow { 1 } else { 0 })) .collect(); let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; @@ -583,6 +613,25 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } s.lights[li].shadow = first; } + // The Environment light's shadow: one orthographic map over the scene from its dominant + // direction (the light travels from the bright region into the scene), after the lights'. + if env_shadow && let Some(e) = s.env { + match sizes.get(s.lights.len()).copied().flatten() { + Some(size) => { + let first = maps.len() as i32; + maps.push(env_shadow_map(e, centre, radius, size)); + if let Some(e) = &mut s.env { + e.shadow = first; + } + } + // The texel budget dropped it. + None => { + if let Some(e) = &mut s.env { + e.shadow = -1; + } + } + } + } let texels: Vec> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect(); for (m, t) in maps.iter_mut().zip(texels) { m.offset = s.shadow_texels.len() as u32; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..d66d8db71 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -172,6 +172,24 @@ pub struct EnvInfo { pub intensity: f32, /// Radians about the vertical axis. pub rotation: f32, + /// Unit direction from the scene towards the environment's dominant (radiance-weighted) + /// bright region — the direction its shadow is cast from, like a parallel light's + /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright + /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow. + pub sun: [f32; 3], + /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the + /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment + /// light's shadow occludes only this much of its light: the key lobe is blocked where + /// the map says so, the remaining fill keeps shining, so the shadow stays partial the + /// way After Effects renders an area-light shadow (a broad environment like the default + /// studio shadows softly, a lone bright spot on black almost fully). + pub key: f32, + /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), + /// -1 casts no shadow. Gated by the light's Cast Shadows. + pub shadow: i32, + pub shadow_darkness: f32, + /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to). + pub shadow_diffusion: f32, } #[derive(Clone, Debug, Default)] @@ -572,7 +590,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c))); } if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { - let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c))); @@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } } -/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour, -/// rotation)). -fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option, [f32; 3], f32)>) { +/// The evaluated Environment light: its image source, intensity × colour, rotation, and +/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). +#[derive(Clone, Copy)] +pub(crate) struct EnvLight { + src: Option, + color: [f32; 3], + rot: f32, + casts_shadows: bool, + shadow_darkness: f64, + shadow_diffusion: f64, +} + +/// Evaluated lights: (scene lights, ambient sum, environment light). +fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; @@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option { @@ -783,8 +819,54 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } +/// The environment image's dominant direction and key strength: the mean texel direction +/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied, +/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or +/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero, +/// and opposing bright regions that cancel leave no mean either — though a chromatic one need +/// not stay directionless once tinted: a red light over equally bright red and blue opposite +/// regions removes the blue pull and points at the red one). The mean of a single bright spot +/// on black points straight at it; a gradient points somewhere between its ends. The returned +/// key fraction is the mean vector's length over the radiance weight — how directional the +/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment +/// light's shadow can remove. +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) { + use super::shade::equirect_dir; + let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); + let mut weight = 0.0f32; + for (i, p) in d.iter().enumerate() { + let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); + let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); + // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the + // environment emits, not the raw texels. + let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin(); + let dir = equirect_dir(u, v, rot); + x += dir[0] * lum; + y += dir[1] * lum; + z += dir[2] * lum; + weight += lum.abs(); + } + // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow + // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. + let len = (x * x + y * y + z * z).sqrt(); + if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { + return ([0.0; 3], 0.0); + } + ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) +} + /// Prefilter an environment image into the scene's textures. -pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) { +pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) { + // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. + // A white (or off) light leaves them as they are. + let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0; + let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; + // The shadow direction and key strength from the full-resolution image, before the mip + // chain averages it — only a shadow-casting light reads them, so only one pays for the + // pass — and weighted by the tint, so the shadow follows the radiance the light emits: + // opposing chromatic regions that cancel untinted need not cancel once the colour removes + // one of them (a red light over equally bright red and blue sides shadows from the red). + let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -807,10 +889,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; - // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { - let tint = intensity.map(|c| c / k); + if tint != [1.0, 1.0, 1.0] { for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { @@ -820,16 +899,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 } } } - b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation }); + b.s.env = Some(EnvInfo { + radiance: first, + mips: levels, + irradiance: ii, + intensity: k, + rotation: env.rot, + sun, + key, + // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). + shadow: if env.casts_shadows { 0 } else { -1 }, + shadow_darkness: env.shadow_darkness as f32, + shadow_diffusion: env.shadow_diffusion as f32, + }); } -/// The environment layer's pixels as linear straight RGBA. -fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> { +/// The Environment light's image: `Absent` when the light has no Source and the comp has no +/// Environment Layer (the default studio environment applies — After Effects "uses a default +/// environment map" for an Environment light too), `Image` when the source renders, `Failed` +/// when a selected or found source produced no pixels (a deleted layer, missing footage, a +/// failed decode). A failed source must not silently become the default environment — that +/// would relight and reshadow the scene behind the user's back — so, like any layer whose +/// source fails renders nothing, it contributes nothing (#2436). +enum EnvPixels { + Absent, + Image((u32, u32, Vec<[f32; 4]>)), + Failed, +} + +fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> EnvPixels { let layer = match src { - Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?, - None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?, + Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)), + None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)), }; - let buf = r.blend_layer_buf(ctx, layer)?; + let Some(layer) = layer else { + // A set Source that resolves to no layer (deleted) failed; an unset one with no + // Environment Layer is simply unconfigured. + return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent }; + }; + let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed }; let img = &buf.img; let data = img .data @@ -843,7 +951,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] } }) .collect(); - Some((img.width, img.height, data)) + EnvPixels::Image((img.width, img.height, data)) +} + +/// The default environment when the light has no Source and no Environment Layer: a dim studio +/// gradient, bright above and toward the initial camera, dark below and behind — After +/// Effects "uses a default environment map" for an Environment light too, and its default +/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience +/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which +/// is not what After Effects' default looks like); the light's colour × intensity still scales +/// the overall level, and its Rotation turns the key around the scene (#2436). +pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { + use super::shade::{dot, equirect_dir}; + // The key region's centre: three quarters up, two thirds toward the viewer (the default + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let sa = (v * std::f32::consts::PI).sin(); + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + data.push([l, l, l, 1.0]); + mean += l * sa; + weight += sa; + } + } + // A dim studio: scale the gradient to a weighted mean of exactly 0.3. + let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) } /// Supersampling factor: 2×2 unless draft or very large. @@ -857,8 +1001,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } - if let Some(item) = r.collapsed(ctx, layer) { - collapsed_layer(r, ctx, layer, item, b); + if let Some(collapsed) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, &collapsed, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) @@ -874,9 +1018,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). -fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; @@ -935,10 +1079,23 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; - if let Some((src, k, rot)) = env - && let Some((w, h, d)) = env_pixels(r, ctx, src, lin) - { - add_environment(&mut b, w, h, d, k, rot); + if let Some(env) = env { + match env_pixels(r, ctx, env.src, lin) { + EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + EnvPixels::Absent => { + let (w, h, d) = default_environment(); + add_environment(&mut b, &env, w, h, d) + } + // A selected or found source that produced no pixels (deleted layer, missing + // footage, failed decode): no environment at all — the light contributes + // nothing, exactly like a layer whose source failed renders nothing, never a + // silent swap to the default studio's lighting and shadows (#2436). + EnvPixels::Failed => {} + } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); @@ -947,6 +1104,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: for l in &mut b.s.lights { l.shadow = -1; } + if let Some(e) = &mut b.s.env { + e.shadow = -1; + } } } // Camera matrices. diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 0ebd0b1ac..820e8fecf 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -228,6 +228,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four +/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the +/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's +/// map position, so a position that differs between backends in its last bits moves the +/// taps by an amount proportional to that difference and does not switch them to another +/// cell's jitter. +fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) { + let corner = |dx: i32, dy: i32| -> (f32, f32) { + let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919))); + (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5) + }; + let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)); + let ax = c00.0 + (c10.0 - c00.0) * wx; + let bx = c01.0 + (c11.0 - c01.0) * wx; + let ay = c00.1 + (c10.1 - c00.1) * wx; + let by = c01.1 + (c11.1 - c01.1) * wx; + (ax + (bx - ax) * wy, ay + (by - ay) * wy) +} + /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). @@ -243,8 +262,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's -/// texel footprint, each tap jittered by an integer hash of the receiver's -/// sub-texel cell and the tap index, and each tap comparing against the four +/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the +/// receiver's sub-texel lattice and the tap index, blended across the receiver's +/// lattice cell), and each tap comparing against the four /// nearest texel centres with bilinear weights. The jitter detaches the tap grid /// from the texel axes (axis-aligned binary taps are what stair-step a straight /// shadow edge), and keying it below texel size keeps the dither finer than an @@ -312,13 +332,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); let step = reach / (n - 1) as f32; let half = (n - 1) as f32 * 0.5; - let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); + let (gu, gv) = (u * 8.0, v * 8.0); + let (cx, cy) = (gu.floor() as i32, gv.floor() as i32); + let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.0; for j in 0..n { for i in 0..n { - let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); - let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; - let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. @@ -362,6 +382,23 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } +/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow +/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant +/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an +/// Environment light's shadow stays partial the way After Effects renders an area-light +/// shadow (a broad environment like the default studio shadows softly, a lone bright spot +/// on black almost fully). A directionless image (or a non-casting light) has no map +/// and shades unshadowed. +pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { + if e.shadow < 0 || e.sun == [0.0; 3] { + return 1.0; + } + let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; + let n_l = dot(n, e.sun).clamp(0.0, 1.0); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); + 1.0 - e.shadow_darkness * e.key * (1.0 - f) +} + /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) { if l.kind == 0 { @@ -509,9 +546,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { let (irr, spec) = env_light(s, e, n, r, rough); let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)]; let (a, b) = env_brdf(n_v, rough); + // The Environment light's shadow darkens its own diffuse and specular. + let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 }; for c in 0..3 { let fs = f0[c] * a + b; - out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao; + out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh; } } let c = add(out, emissive); @@ -537,5 +576,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { } amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } + // The Environment light's shadow counts too (it reaches every surface: no falloff). + if let Some(e) = &s.env { + amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n)); + } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index f9a0f2066..b2612de98 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -2,7 +2,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build}; use effectcraft_time::{FrameRate, Tick}; -use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map}; +use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map}; use super::shade::{self, PI}; use super::*; use crate::{NoFootage, RenderOpts, Renderer}; @@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() { assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]); } +/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds +/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns +/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow +/// (#2436). +#[test] +fn a_non_finite_environment_has_no_sun_direction() { + let nan = vec![[f32::NAN; 4]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0)); +} + +/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is +/// almost fully directional, and a broad gradient sits in between — the soft studio default +/// shadows visibly but keeps most of its light as fill (#2436). +#[test] +fn environment_key_strength_tracks_directionality() { + let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0); + let mut spot = vec![[0.0f32; 4]; 64 * 32]; + spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; + let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0); + assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); + let (w, h, d) = scene::default_environment(); + let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); +} + +/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at +/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel, +/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no +/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing +/// at the brighter spot. +#[test] +fn opposing_bright_regions_cancel_to_no_shadow() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48 + // sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are + // exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 1.0, 1.0, 1.0]; + opposed[b] = [1.0, 1.0, 1.0, 1.0]; + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // One side twice as bright: the mean points at the brighter spot with its share of the + // weight (key = 1/3 — two parts against one). + let mut tilted = opposed.clone(); + tilted[a] = [2.0, 2.0, 2.0, 1.0]; + let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0); + assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); +} + +/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red +/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the +/// red light's tint removes the blue side's pull, so the mean points straight at the red one +/// (a blue light, at the blue one): the shadow lands where the emitted light comes from. +#[test] +fn a_coloured_light_shadows_from_the_region_it_tints() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two + // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 0.0, 0.0, 1.0]; + opposed[b] = [0.0, 0.0, 1.0, 1.0]; + // To a white light the channel sums are equal: they cancel, no shadow. + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot. + // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same + // way; only the channel ratios matter.) + let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}"); + // A blue light points at the blue one instead. + let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0); + let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the blue side wins: {sun:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -337,6 +421,365 @@ fn cards_and_environment_light() { assert_eq!(lit.data[80 * 120 + 110][3], 0.0); } +/// An Environment light with no Source and no Environment Layer lights the scene with a flat +/// default environment of its colour × intensity — After Effects "uses a default environment +/// map" for an Environment light — not nothing at all (#2436). +#[test] +fn environment_light_without_a_source_lights_the_scene() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + let c = unlit.data[20 * 120 + 30]; + assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}"); + // No Source, no Environment Layer: the light still lights the card (a flat white + // environment at the light's colour × intensity; a card's Material Options shade it). + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {}); + let lit = render(&p, cid); + let c2 = lit.data[20 * 120 + 30]; + assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}"); + // The light's parameters drive it: half intensity darkens towards zero intensity's black. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0)); + let half = render(&p, cid); + let c3 = half.data[20 * 120 + 30]; + assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}"); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0)); + let dark = render(&p, cid); + let c4 = dark.data[20 * 120 + 30]; + assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}"); + // And its colour tints the light red. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0)); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + let red = render(&p, cid); + let c5 = red.data[20 * 120 + 30]; + assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); +} + +/// #2436 review: a selected or auto-picked environment source that fails to render (missing +/// footage, a deleted layer) must not silently swap in the default studio environment — the +/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a +/// light with no Source at all (and no Environment Layer in the comp) uses the default. +#[test] +fn a_failed_environment_source_lights_nothing() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + // The Environment Layer's footage is unreadable (missing; NoFootage serves no frames). + let fid = p.add_item( + "F", + Default::default(), + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "missing.exr".into(), + kind: effectcraft_project::FootageKind::Still, + width: 64, + height: 32, + pixel_aspect: 1.0, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + missing: true, + ..Default::default() + }), + ); + add(&mut p, cid, LayerSource::Footage { item: fid }, |l| { + l.switches.three_d = true; + l.environment = true; + }); + let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id; + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer { + p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap() + } + // Auto-picked Environment Layer, unreadable: the light is off — no studio default, no + // default-environment shadow either. + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing"); + // The light's Source set to that unreadable layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing"); + // The Source pointing at a deleted layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing"); + // Contrast: with no Source and no Environment Layer, the default studio still lights it. + let comp = p.comp_mut(cid).unwrap(); + comp.layers.retain(|l| l.is_light() || !l.environment); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None)); + let lit = render(&p, cid); + let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]); + assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}"); +} + +/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant +/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to +/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below +/// it; Cast Shadows off lights it evenly. +#[test] +fn environment_lights_cast_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a tight bright key above the image's centre, on black — a + // full-comp rect keeps the layer buffer full-size, the radial gradient is the key. + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let key = build::shape_gradient_fill( + ids, + true, + [0.0, -30.0], + [0.0, 5.0], + effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Key", vec![rect, key]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + // Turned 180°, the key shines from up and behind the camera onto the sphere and floor. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/rotation", Value::Scalar(180.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The shadow lands below the sphere on the floor: its core is the darkest floor pixel + // below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded, + // so the ratios below are gamma-compressed (a linear halving reads as ~0.73). + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 25, 75); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 100..150 { + for x in 60..140 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + +/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts +/// nothing — the render is the same as with the option off. +#[test] +fn a_uniform_environment_casts_no_shadow() { + let (mut p, cid) = project(120, 90); + let card = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true); + let env = p.add_item( + "E", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: env }, |l| { + l.environment = true; + l.switches.three_d = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let off = render(&p, cid); + assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit"); + assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); +} + +/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue +/// regions on exactly opposite sides of the image cancel to a white light (it casts no +/// shadow), but the red Environment light's tint removes the blue region's pull, so the red +/// one becomes the key and casts the shadow the user asked for, from the red side. +#[test] +fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a black background with a red square and an equally bright blue + // one at exactly antipodal image positions (the blue is the point mirror of the red through + // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a + // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up, + // left and toward the camera; the blue down, right and away from it. + add(&mut p, cid, LayerSource::Shape, |l| { + l.environment = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + // Contents stack like the AE timeline — the first group is on top — so the background + // goes in last, under the red and blue squares (and its full-comp rect keeps the + // layer buffer full-size: a shape layer's buffer otherwise crops to its content). + let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0); + let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]); + let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0); + let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]); + let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]); + let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]); + let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]); + for g in [red, blue, bg] { + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + }); + // A white light over the chromatic image: the regions cancel — Cast Shadows changes + // nothing, exactly like a directionless image. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let white_casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false)); + let white_off = render(&p, cid); + assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue"); + // The red light: the tint removes the blue region, the red one shadows the sphere onto the + // floor below and right of it (the shadow falls opposite the red region). + let comp = p.comp_mut(cid).unwrap(); + let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap(); + set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + set(light, "lightOptions/castsShadows", Value::Bool(true)); + let img = render(&p, cid); + let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0]; + let side = chan(&img, 25, 75); + let (mut core, mut cx) = (f32::INFINITY, 0usize); + for y in 100..150 { + for x in 60..170 { + if chan(&img, x, y) < core { + core = chan(&img, x, y); + cx = x; + } + } + } + assert!(side > 0.1, "the floor is lit red: {side}"); + assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}"); + assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}"); +} + +/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean +/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out +/// (After Effects' default does not) — and directional: its dominant region points up and +/// toward the initial camera, so a shadow-casting Environment light casts soft, partial +/// shadows out of the box, like After Effects' default. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + mean += (p[0] + p[1] + p[2]) / 3.0 * sa; + weight += sa; + } + assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); + let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!(sun[1] < -0.5, "the key is up: {sun:?}"); + assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); + assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); + assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}"); + // No texel clips past white at the default intensity (the fog of a mean-1 environment). + assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1"); +} + +/// #2436: with no Source and no Environment Layer, the Environment light's default environment +/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows +/// the plane behind it, darkening part of the light while the fill keeps the floor visible +/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights +/// the floor evenly. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The default key (up and toward the camera) drops the shadow below the sphere: the core + // is the darkest floor pixel below it, the lit floor the sample to the left. + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 30, 100); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 110..150 { + for x in 80..120 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + // Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining — + // not the pitch black a point light's shadow would cast at the same darkness. The frame + // is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73). + assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); @@ -370,7 +813,16 @@ fn extruded_text_renders_as_a_mesh() { set(l, "geometryOptions/bevelStyle", Value::Enum(1)); set(l, "transform/rotationY", Value::Scalar(40.0)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -409,7 +861,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -445,7 +906,16 @@ fn text_animators_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -643,7 +1113,16 @@ fn extruded_strokes_render_as_bevelled_meshes() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -887,6 +1366,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test] +fn penumbra_jitter_is_continuous_across_its_lattice() { + // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice, + // across u and across v, inside the penumbra. The GPU places a receiver within a few + // ulps of the CPU, so coverage that jumps at a lattice line differs between the two + // backends there; both sides of each line must agree within one 8-bit level. + let (m, texels) = wall_map(64, 1.0, 6.0); + let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0); + let mut worst = 0.0f32; + for k in 0..16 { + let line = k as f32 * 0.125; + for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] { + let (fa, fb) = (f(a), f(b)); + assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}"); + worst = worst.max((fa - fb).abs()); + } + } + assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line"); +} + #[test] fn penumbra_width_is_independent_of_map_resolution() { // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and @@ -1047,3 +1546,54 @@ fn hard_edges_ramp_over_two_texels() { assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); } } + +// ---------------------------------------------------------------- the After Effects look (#2436) + +/// #2436: an Environment light with every option at its default renders like After Effects': +/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the +/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining — +/// not the pitch black a point light casts), and a soft edge in both quality and draft renders +/// (the map's texel grid never shows through as stair-steps). +#[test] +fn environment_shadows_match_the_after_effects_look() { + let (mut p, cid) = project(800, 600); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(1200.0)); + set(l, "geometryOptions/height", Value::Scalar(900.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(80.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0; + for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] { + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // The shadow core: the darkest pixel of the lower half (the key drops the shadow + // below the sphere); the lit floor is the mean of the band to its left. + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 420..580 { + for x in 300..500 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::() / 200.0; + assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}"); + // The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving + // reads as ~0.73): partial means clearly darker than lit, far from black. + assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}"); + // The edge's 10-90 % ramp from the lit plateau (left) down into the shadow. + let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i); + let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32); + assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)"); + } +} diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 12bcbcf37..098f095d0 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { return bokeh_blur(img, iris, levels[0] as f64); } let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); - let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + // A pixel blends only the two levels around its radius, so the levels are made in order and + // only that pair is held: peak memory stays a few images whatever the level count, rather + // than one image per level. Each level's blur is parallel over its rows. let mut out = Image::new(img.width, img.height); - out.data.par_iter_mut().enumerate().for_each(|(i, o)| { - let r = radius[i]; - let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); - let k = (f.floor() as usize).min(n - 2); - let t = f - k as f32; - let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); - *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); - }); + let mut below = bokeh_blur(img, iris, lo as f64); + for (k, &r) in levels.iter().enumerate().skip(1) { + let above = bokeh_blur(img, iris, r as f64); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + if kk + 1 == k { + let t = f - kk as f32; + let (a, b) = (below.data[i], above.data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + } + }); + below = above; + } out } diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 30873b6a9..a927e2ceb 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -241,6 +242,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { .filter(|k| **k <= ctx.time) .find_map(|&k| dir(pos(k) - pos(k - dt))) .or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k)))) + .or_else(|| { + // An expression has no keys to fall back on: sample it instead. + let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression())); + expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten() + }) +} + +/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame. +const MOTION_SEARCH_STEPS: u32 = 12; + +/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a +/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away +/// (within the layer's in/out points) until the position differs from the current one, then +/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the +/// one at the end of the motion whichever frame is shown. +fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option { + let here = pos(ctx.time); + let moved = |t| (pos(t) - here).length() > 1e-9; + let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) }; + let mut step = ctx.comp.frame_duration().max(dt); + let (mut still, mut away) = (ctx.time, None); + for _ in 0..MOTION_SEARCH_STEPS { + let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) }; + if moved(t) { + away = Some(t); + break; + } + still = t; + if t == limit { + break; + } + step = step + step; + } + let mut away = away?; + for _ in 0..MOTION_SEARCH_STEPS { + if (still - away).abs() <= dt { + break; + } + let mid = away + (still - away).mul_ratio(1, 2); + if moved(mid) { + away = mid; + } else { + still = mid; + } + } + let d = if earlier { here - pos(away) } else { pos(away) - here }; + (d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize()) } /// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees. @@ -313,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 8e932819a..a5a7c170e 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. -pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() @@ -218,7 +218,7 @@ struct PlaneInput { } /// Prepare the plane's geometry, motion samples and material. -fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; @@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: us } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer +/// can still be blurred. The blur holds a few images of this size at once whatever its level +/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than +/// blurring a large layer. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } @@ -449,8 +503,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; - if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + if let Some(collapsed) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, &collapsed, i, out); } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { @@ -469,6 +523,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, extra = ctx .comp .layers + .as_arcs() .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) @@ -483,20 +538,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, /// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they /// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack /// order. (Nested adjustment layers are skipped here.) -fn collapsed_items<'a>( - r: &Renderer<'a>, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - item: effectcraft_project::ItemId, - order: usize, - out: (u32, u32), -) -> Vec> { +fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.any_solo(); - let nested: Vec<&'a Layer> = nctx - .comp + let Some(nc) = r.project.comp(collapsed.item) else { return vec![] }; + let any_solo = nc.any_solo(); + let nested: Vec<&'a Layer> = nc .layers .iter() .rev() @@ -645,6 +693,13 @@ fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState], [lit[0] * a, lit[1] * a, lit[2] * a, a] } +/// Samples per side of the Shadow Diffusion kernel for a penumbra radius of `r_px` caster pixels: +/// odd, at least 3, and enough that neighbouring samples are at most 1.5 pixels apart, up to 15. +pub(crate) fn shadow_taps(r_px: f64) -> u32 { + let n = ((2.0 * r_px / 1.5).ceil() as u32).clamp(3, 15); + n | 1 +} + /// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded). fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { const FAR: f64 = 1.0e6; @@ -672,11 +727,17 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { if r * c.buf.scale < 0.5 { px = c.texel(hit.x, hit.y); } else { - for j in -1..=1 { - for i in -1..=1 { - let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66); + // Box filter over the penumbra square: n × n cell centres, dense enough that a wide + // diffusion blurs the caster instead of stamping copies of it (#2444). + let n = shadow_taps(r * c.buf.scale); + let step = 2.0 * r / n as f64; + let h = (n / 2) as i32; + let w = 1.0 / (n * n) as f32; + for j in -h..=h { + for i in -h..=h { + let q = c.texel(hit.x + f64::from(i) * step, hit.y + f64::from(j) * step); for k in 0..4 { - px[k] += q[k] / 9.0; + px[k] += q[k] * w; } } } @@ -850,7 +911,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh if nv[2] > 0.0 { nv = nv.map(|v| -v); } - let (sw, sh) = crate::source_size(r.project, it.layer); + let (sw, sh) = ctx.source_size(it.layer); let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) }) diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 9f1dbb344..026929623 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -344,10 +349,37 @@ fn shadow_darkness_and_accepts_off() { assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}"); } +#[test] +fn shadow_diffusion_is_a_soft_ramp_not_copies() { + // #2444: a wide Shadow Diffusion stamped 3 × 3 copies of the caster, so the penumbra stepped + // through three flat levels instead of ramping smoothly from lit to shadowed. + let (mut p, cid, comp) = setup(); + let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]); + no_specular(&mut recv); + let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]); + set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster + let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]); + set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0])); + set(&mut l, "lightOptions/castsShadows", Value::Bool(true)); + // 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius. + set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0)); + push(&mut p, cid, l); + push(&mut p, cid, caster); + push(&mut p, cid, recv); + let img = render(&p, cid); + let row: Vec = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect(); + // The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit. + assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}"); + let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count(); + assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}"); + let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max); + assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}"); +} + // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -423,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index 06f2599dc..f9c72e296 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource use effectcraft_time::{FrameRate, Tick}; use super::active_camera; +use super::camera::auto_orient_2d; use crate::EvalCtx; fn setup() -> (Project, ItemId, Comp) { @@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() { } } +/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by +/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the +/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees. +#[test] +fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() { + /// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like + /// a slider going from 0% to 100%). + struct LeftThenHold; + impl crate::ExprHost for LeftThenHold { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0); + Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0])) + } + } + for three_d in [false, true] { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + let pos = l.props.prop_mut("transform/position").unwrap(); + assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes"); + pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] { + let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) }; + let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0)); + // Moving left (-x): the layer's x axis points left before the motion starts and after it stops. + assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}"); + } + } +} + +/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end +/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on. +#[test] +fn orient_along_a_curved_expression_path_keeps_the_end_heading() { + /// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end. + struct Arc; + impl crate::ExprHost for Arc { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2; + Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0])) + } + } + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for frame in [31, 33, 37, 45, 59] { + let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) }; + let deg = auto_orient_2d(&ctx, &c.layers[0]); + assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}"); + } +} + #[test] fn layers_parented_to_a_camera_follow_its_rotation() { let (mut p, cid, comp) = setup(); diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index b64aaab37..5837f77b8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> { const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +/// Animator property ids and their stable After Effects scripting names. Shared by lookup, +/// `.matchName`, and `addProperty` so the same property has one name in every direction. +pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ + ("anchor", "ADBE Text Anchor Point 3D"), + ("position", "ADBE Text Position 3D"), + ("scale", "ADBE Text Scale 3D"), + ("skew", "ADBE Text Skew"), + ("rotation", "ADBE Text Rotation"), + ("opacity", "ADBE Text Opacity"), + ("fillColor", "ADBE Text Fill Color"), + ("fillHue", "ADBE Text Fill Hue"), + ("fillSaturation", "ADBE Text Fill Saturation"), + ("fillBrightness", "ADBE Text Fill Brightness"), + ("fillOpacity", "ADBE Text Fill Opacity"), + ("strokeColor", "ADBE Text Stroke Color"), + ("strokeHue", "ADBE Text Stroke Hue"), + ("strokeSaturation", "ADBE Text Stroke Saturation"), + ("strokeBrightness", "ADBE Text Stroke Brightness"), + ("strokeOpacity", "ADBE Text Stroke Opacity"), + ("strokeWidth", "ADBE Text Stroke Width"), + ("tracking", "ADBE Text Tracking Amount"), + ("lineAnchor", "ADBE Text Line Anchor"), + ("lineSpacing", "ADBE Text Line Spacing"), + ("characterOffset", "ADBE Text Character Offset"), + ("characterValue", "ADBE Text Character Replace"), + ("blur", "ADBE Text Blur"), +]; + +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -147,6 +185,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"), + ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -208,6 +247,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"), + ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"), + ("wiggler", "correlation", "ADBE Vector Correlation"), + ("wiggler", "phase", "ADBE Vector Temporal Phase"), + ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"), + ("wiggler", "seed", "ADBE Vector Random Seed"), + ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ("wiggle", "size", "ADBE Vector Roughen Size"), ("wiggle", "detail", "ADBE Vector Roughen Detail"), @@ -401,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle", + "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, }) @@ -487,6 +533,9 @@ pub fn node(chain: &[&Node]) -> String { "text" if pid == "animators" => "ADBE Text Animator".into(), "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own) + } "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), "lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own), @@ -537,6 +586,30 @@ mod tests { assert_eq!(shape_kind("nope"), None); } + #[test] + fn text_animator_property_names_are_canonical_and_scoped() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_PROPERTIES { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids"); + } + } + + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 23356c064..258a6de2d 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R { /// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match /// name or a display name. fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ - ("ADBE Text Anchor Point 3D", "anchor"), - ("ADBE Text Position 3D", "position"), - ("ADBE Text Scale 3D", "scale"), - ("ADBE Text Skew", "skew"), - ("ADBE Text Rotation", "rotation"), - ("ADBE Text Opacity", "opacity"), - ("ADBE Text Fill Color", "fillColor"), - ("ADBE Text Fill Hue", "fillHue"), - ("ADBE Text Fill Saturation", "fillSaturation"), - ("ADBE Text Fill Brightness", "fillBrightness"), - ("ADBE Text Fill Opacity", "fillOpacity"), - ("ADBE Text Stroke Color", "strokeColor"), - ("ADBE Text Stroke Hue", "strokeHue"), - ("ADBE Text Stroke Saturation", "strokeSaturation"), - ("ADBE Text Stroke Brightness", "strokeBrightness"), - ("ADBE Text Stroke Opacity", "strokeOpacity"), - ("ADBE Text Stroke Width", "strokeWidth"), - ("ADBE Text Tracking Amount", "tracking"), - ("ADBE Text Line Anchor", "lineAnchor"), - ("ADBE Text Line Spacing", "lineSpacing"), - ("ADBE Text Character Offset", "characterOffset"), - ("ADBE Text Character Replace", "characterValue"), - ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + let table = matchnames::TEXT_ANIMATOR_PROPERTIES; + if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f012ce128..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -734,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); @@ -1707,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 608c83d0e..2c18039e8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 4760d058b..ddfbc1f3e 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -745,7 +745,8 @@ function __uiMouseWanted(c, inherited) { } // A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, -// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +// 2 right), buttons (held: 1 left, 2 right, 4 middle), detail (click count), screenX, screenY, +// altKey, ctrlKey, metaKey, shiftKey}. function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); @@ -765,6 +766,7 @@ function __uiMouseEvent(type, target, v) { timeStamp: new Date(), detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), button: num(v.button, 0), + buttons: num(v.buttons, 0), clientX: num(v.x, 0), clientY: num(v.y, 0), screenX: num(v.screenX, 0), @@ -910,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) { w.close(v === null ? 2 : v); return; } + // The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script. + if (kind === "resize") { + var nw = Number(v && v[0]), nh = Number(v && v[1]); + var b = w.__ub || w.__lb; + if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) { + w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh]; + __uiLayout(w); + w.__fire("onResizing"); + w.__fire("onResize"); + } + return; + } var c = __uiFind(w, id); if (!c || c.enabled === false) return; var radio = function () { diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 4103f12c1..c024fcad5 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -394,19 +394,33 @@ mod tests { /// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls. /// In particular, Windows may accept that entire write with its default send buffering. + /// Writes must stay blocked for [`SETTLED`] before the buffers count as full: on macOS the + /// accepted stream does not keep the listener's 4 KiB receive buffer (it gets about 114 KiB), + /// so the first `WouldBlock` only means the 4 KiB send buffer is full, and the send buffer then + /// drains into the peer's larger receive buffer. fn backpressure(id: u32) { + const SETTLED: Duration = Duration::from_millis(200); let mut stream = super::imp::test_stream(id); socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); stream.set_nonblocking(true).unwrap(); let start = Instant::now(); let chunk = [b'x'; 4096]; let mut sent = 0; + let mut blocked_since = None; loop { assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure"); match stream.write(&chunk) { Ok(0) => panic!("peer closed while establishing backpressure"), - Ok(n) => sent += n, - Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Ok(n) => { + sent += n; + blocked_since = None; + } + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if blocked_since.get_or_insert_with(Instant::now).elapsed() >= SETTLED { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } Err(e) if e.kind() == ErrorKind::Interrupted => continue, Err(e) => panic!("establishing backpressure: {e}"), } diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index a2f674d0a..0af09841a 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -17,6 +17,7 @@ mod include; mod panels; #[cfg(not(windows))] mod services; +mod startup; mod tasks; mod ui; mod ui_more; @@ -371,6 +372,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties. +#[test] +fn wiggle_transform_by_match_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("W", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var root = sh.property("ADBE Root Vectors Group"); + var w = root.addProperty("ADBE Vector Filter - Wiggler"); + var byName = root.addProperty("Wiggle Transform"); + w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]); + [ + w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value, + w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name + ] + "#, + ); + assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"])); + let (_, c) = comp_named(&s, "W"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0])); +} + #[test] fn keyframes_eases_and_interpolation() { let mut s = session(); @@ -1062,6 +1088,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } @@ -1719,6 +1750,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name, + added.matchName, props.property(added.matchName).propertyIndex] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); +} + +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test] diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index 009cc59c6..f075250b2 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -608,3 +608,37 @@ fn presets_saved_by_the_old_core_panel_carry_over() { let win = open_panel(&mut s); assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); } + +/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the +/// unused first-in and last-out sides; editing its speeds makes a Bezier curve. +#[test] +fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + // Start from Bezier keys so Linear has something to restore. + ok( + &mut s, + r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType; + for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#, + ); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}"); + } + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}"); + } + // Edited speeds make a custom Bezier curve even though Linear is still the selected preset. + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]); + let k = &prop(&s, l, "transform/opacity").keys[1]; + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); +} diff --git a/crates/script/src/tests/panels.rs b/crates/script/src/tests/panels.rs index c398a3a3f..3318d060f 100644 --- a/crates/script/src/tests/panels.rs +++ b/crates/script/src/tests/panels.rs @@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() { let mut s = session(&store); assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []})); } + +/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens +/// titled after its file and opens again at the next launch. +#[test] +fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap(); + let mut s = session(&store); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate")); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); + // A saved name that leaves the folder, or goes two folders deep, fails and is forgotten. + store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap(); + drop(s); + let mut s = session(&store); + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"])); + assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["../Top.jsx", "a/b/c.jsx"]); + assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some()); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); +} diff --git a/crates/script/src/tests/startup.rs b/crates/script/src/tests/startup.rs new file mode 100644 index 000000000..67da833c5 --- /dev/null +++ b/crates/script/src/tests/startup.rs @@ -0,0 +1,290 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn startup_scripts_tasks_go_on_after_a_project_opens() { + use web_time::{Duration, Instant}; + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap(); + // A task scheduled after the script's dialog closes. + store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap(); + let mut s = session(&store); + s.execute("file.runStartupScripts", json!({})).unwrap(); + s.execute("scriptui.close", json!({})).unwrap(); + // The launch opens a project after the Startup scripts ran. + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500)); + assert_eq!(r["ran"], json!(2), "{r}"); + // A script run from File ▸ Scripts still stops when the project closes. + s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap(); + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000)); + assert_eq!(r["ran"], json!(2), "{r}"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 228a58016..483d686ef 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] } diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 082a16cac..ab6f69846 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv // ---------------------------------------------------------------- scheduled tasks +/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window +/// open and is not a Startup script's. +fn ends_with_project(session: &Session, host: u32) -> bool { + !session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host) +} + /// Run the due tasks of the script-host threads; hosts without windows whose project was closed -/// end. +/// end, except Startup scripts' hosts. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { let epoch = session.script_ui.project_epoch; @@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr if let Ok(mut h) = hosts().lock() { for host in session.script_ui.task_hosts.clone() { let Some(end) = h.get(&host) else { continue }; - let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); - if windowless && end.epoch != epoch { + if end.epoch != epoch && ends_with_project(session, host) { // Dropping its end lets the host thread finish. stale.extend(h.remove(&host)); } else if end.next_due.is_some_and(|d| d <= now) { @@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr } /// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those -/// without windows whose project was closed stop. +/// without windows whose project was closed stop, except Startup scripts' hosts. pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { for (host, name, epoch) in crate::tasks::hosts() { if !session.script_ui.task_hosts.contains(&host) { @@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat } let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); - if !alive || (windowless && epoch != session.script_ui.project_epoch) { + if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) { crate::tasks::drop_host(host); if host != CONSOLE_HOST && windowless { drop_inline(host); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index d2720bb0d..95c00a91a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1261,6 +1261,10 @@ fn xml_depth(text: &str) -> usize { max } +/// Nodes a document may expand to: a few kilobytes of nested internal entities +/// (``) otherwise expand to millions and exhaust memory. +const MAX_XML_NODES: u32 = 4_000_000; + /// Parse an SVG document. pub fn parse(bytes: &[u8]) -> Result { let text = String::from_utf8_lossy(bytes); @@ -1268,7 +1272,8 @@ pub fn parse(bytes: &[u8]) -> Result { if xml_depth(text) > MAX_XML_DEPTH { return Err(Error::Xml(format!("elements nested more than {MAX_XML_DEPTH} levels deep"))); } - let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + // DTDs stay allowed (Illustrator's SVGs declare entities), but expansion is bounded (#2129). + let opts = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: MAX_XML_NODES, ..Default::default() }; let xml = roxmltree::Document::parse_with_options(text, opts).map_err(|e| Error::Xml(e.to_string()))?; let root = xml.root_element(); if root.tag_name().name() != "svg" { diff --git a/crates/svg/src/pathdata.rs b/crates/svg/src/pathdata.rs index c8fa7f67f..7a2762db7 100644 --- a/crates/svg/src/pathdata.rs +++ b/crates/svg/src/pathdata.rs @@ -226,9 +226,18 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las let sweep = l.flag()?; let e = pt(l)?; let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep }; - match kurbo::Arc::from_svg_arc(&arc) { - Some(a) => a.to_cubic_beziers(0.1, |c1, c2, end| p.curve_to(c1, c2, end)), - None => p.line_to(e), + let mut last_end = None; + if let Some(a) = kurbo::Arc::from_svg_arc(&arc) { + // Scale tolerance with the radius so the segment count stays bounded. + let tol = 0.1_f64.max(a.radii.x.max(a.radii.y) * 1e-4); + a.to_cubic_beziers(tol, |c1, c2, end| { + p.curve_to(c1, c2, end); + last_end = Some(end); + }); + } + // Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point. + if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) { + p.line_to(e); } *cur = e; *last_cubic = None; @@ -261,6 +270,12 @@ mod tests { use super::*; use kurbo::PathEl; + #[test] + fn huge_arc_radius_stays_bounded() { + let p = parse_path_data("M0 0A1e36 1e36 0 1 1 1e30 0"); + assert!(p.elements().len() < 1000, "got {} elements", p.elements().len()); + } + #[test] fn commands() { let p = parse_path_data("M10,10 L20,10 h10 v10 H10 z m5 5 l1-1.5.5.5e1 C 0 0 1 1 2 2 s3 3 4 4 Q1 1 2 2 t3 3 A5 5 0 0 1 20 20 a5 5 0 1010 0"); @@ -294,6 +309,18 @@ mod tests { assert!(parse_path_data("M0 0 L10 0 z 9 9").elements().len() <= 4); } + #[test] + fn huge_radius_arc_still_reaches_end_point() { + for d in ["M0 0 A1e20 1e20 0 0 1 10 0 L10 10", "M0 0 A1e20 1e20 0 1 1 10 0 L10 10", "M0 0 a1e20 1e20 0 0 1 10 0 L10 10"] { + let p = parse_path_data(d); + let reaches = p.elements().iter().any(|e| match e { + PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => (q.x - 10.0).abs() < 1e-6 && q.y.abs() < 1e-6, + _ => false, + }); + assert!(reaches, "{d}: {:?}", p.elements()); + } + } + #[test] fn number_lists() { assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 31c95e62f..06dfbd333 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -220,6 +220,18 @@ fn transforms() { assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9); } +#[test] +fn nested_entities_are_refused_instead_of_expanding_without_bound() { + // #2129: each top-level `&b;` stays under roxmltree's per-reference cap but adds 12 500 + // nodes, so 40 KB of references expands to 5 million. + let dtd = format!("", "".repeat(50), "&a;".repeat(250)); + let bomb = format!("{}", "&b;".repeat(400)); + assert!(matches!(crate::parse(bomb.as_bytes()), Err(crate::Error::Xml(_)))); + // An ordinary entity, as Illustrator declares them, still parses. + let ok = "]>"; + assert!(crate::parse(ok.as_bytes()).is_ok()); +} + #[test] fn deep_nesting_errors_instead_of_overflowing_the_stack() { // 100 000 nested groups overflowed the XML parser's stack and aborted the process. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a973335f4..5388629cf 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -385,7 +385,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -393,6 +394,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -707,14 +762,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 079a001e0..1f7e0d46f 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -1,5 +1,14 @@ use super::*; +/// Run `test` again in a child process with `variable` set. The fixture faces below go into the +/// process-global font database and its fallback caches, so only a process that runs one such test +/// on its own can use them. +#[cfg(not(target_arch = "wasm32"))] +fn run_in_child(variable: &str, test: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", test, "--nocapture"]).env(variable, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} + /// Concurrent language requests must neither choose another context's font nor reuse its /// cached family order. Synthetic fonts in a child process keep this independent of the host /// font installation and of other tests that exercise the process-global database/locale. @@ -8,12 +17,7 @@ use super::*; fn cjk_requests_keep_their_language_and_cache_entries() { const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; if std::env::var_os(CHILD).is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + run_in_child(CHILD, "fonts::tests::cjk_requests_keep_their_language_and_cache_entries"); return; } let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; @@ -71,6 +75,52 @@ fn cjk_requests_keep_their_language_and_cache_entries() { } } +/// #453: with a Chinese interface, a Han ideograph that both a Chinese and a Japanese face cover +/// must reach the installed Simplified Chinese family first, or Chinese text in a composition is +/// drawn with Japanese glyph forms. Fixture faces named after the macOS families keep this +/// independent of the host's fonts and of rendering. Before the fix the Simplified list named none +/// of the macOS faces a stock install has, so the walk below reached the Japanese face - the +/// regression this guards. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zh_hans_han_fallback_puts_the_chinese_family_first() { + const CHILD: &str = "EFFECTCRAFT_TEST_ZH_HANS_ORDER"; + if std::env::var_os(CHILD).is_none() { + run_in_child(CHILD, "fonts::tests::zh_hans_han_fallback_puts_the_chinese_family_first"); + return; + } + let mut ids = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + // The Japanese face is added first, so scan order cannot be what picks the Chinese one. + for family in ["Hiragino Sans", "Hiragino Sans GB"] { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + ids.push(push(&mut d, info_from(names, FaceData::Shared(data), "test"))); + } + } + let &[japanese, chinese] = ids.as_slice() else { panic!("two fixture faces expected: {ids:?}") }; + let base = resolve("Inter", "Regular").face; + assert!(face(japanese).has_char('页') && face(chinese).has_char('页')); + // The fallback list itself, before any coverage check: the Chinese family comes first for + // zh-hans and second for ja, whether or not either face can draw the probe. + let zh_hans: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Simplified).to_vec(); + assert_eq!(zh_hans, [chinese, japanese], "zh-hans tries the Chinese family first"); + let ja: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Japanese).to_vec(); + assert_eq!(ja, [japanese, chinese], "ja keeps the Japanese family first"); + // ...and the face a layout actually gets for the shared ideograph. + assert_eq!(face(fallback_for_locale('页', base, "zh-hans")).info.family, "Hiragino Sans GB"); + assert_eq!(face(fallback_for_locale('页', base, "ja")).info.family, "Hiragino Sans"); +} + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -93,24 +143,47 @@ fn bundled_families_and_resolution() { assert!(!b.synth_bold); let faces = all_faces(); assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled")); - // A real installed Bold Italic takes priority. Partial installed families can instead - // supply the nearest weight/slant, so neither synthesis flag is fixed by the OS. + // A real installed Bold Italic — the face's own style, or a named instance of a variable + // face — takes priority. Partial installed families can instead supply the nearest + // weight/slant, so neither synthesis flag is fixed by the OS. for family in ["Noto Serif", "Inter"] { let resolved = resolve(family, "Bold Italic"); let selected = face(resolved.face); assert!(selected.info.family.eq_ignore_ascii_case(family)); assert!(!resolved.missing); let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); - if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) { + let exact = candidates + .iter() + .find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) + .or_else(|| candidates.iter().find(|f| f.info.instances.iter().any(|i| i.style.eq_ignore_ascii_case("Bold Italic")))); + if let Some(exact) = exact { assert_eq!(resolved.face, exact.id, "real style wins for {family}"); + let expected_instance = if exact.info.style.eq_ignore_ascii_case("Bold Italic") { + None + } else { + exact.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case("Bold Italic")).and_then(|i| u16::try_from(i).ok()) + }; + assert_eq!(resolved.instance, expected_instance, "exact style instance for {family}"); assert!(!resolved.synth_bold && !resolved.synth_italic); } else { - // Italic mismatch dominates any supported font weight distance. - let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 }; - let nearest = candidates.iter().map(|f| distance(f)).min().unwrap(); - assert_eq!(distance(&selected), nearest, "closest available style for {family}"); - assert_eq!(resolved.synth_bold, selected.info.weight <= 500); - assert_eq!(resolved.synth_italic, !selected.info.italic); + // Italic mismatch dominates any supported font weight distance. Named instances + // resolve at their effective weight and slant, not the face's defaults. + let selected_instance = resolved.instance.and_then(|i| selected.info.instances.get(usize::from(i))); + let selected_weight = selected_instance.map_or(selected.info.weight, |i| i.weight(selected.info.weight)); + let selected_italic = selected_instance.map_or(selected.info.italic, |i| i.italic(selected.info.italic)); + let distance = |weight: u16, italic: bool| u32::from(weight.abs_diff(700)) + if italic { 0 } else { 1_000 }; + let nearest = candidates + .iter() + .flat_map(|f| { + std::iter::once((f.info.weight, f.info.italic)) + .chain(f.info.instances.iter().map(move |i| (i.weight(f.info.weight), i.italic(f.info.italic)))) + }) + .map(|(weight, italic)| distance(weight, italic)) + .min() + .unwrap(); + assert_eq!(distance(selected_weight, selected_italic), nearest, "closest available style for {family}"); + assert_eq!(resolved.synth_bold, selected_weight <= 500); + assert_eq!(resolved.synth_italic, !selected_italic); } } } @@ -130,6 +203,8 @@ fn system_scan_finds_fonts() { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); @@ -164,6 +239,7 @@ fn variable_named_instances_resolve_and_list() { ("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")), ("Black", 900.0, Some("EffectCraftInstanceTest-Black")), ("Extra Bold", 800.0, None), + ("Bold Italic", 700.0, Some("EffectCraftInstanceTest-BoldItalic")), ]; assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1); let wght = |r: &Resolved| instance_variations(r); @@ -185,10 +261,15 @@ fn variable_named_instances_resolve_and_list() { assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]); assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]); assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]); + // A named instance keeps its own index, so layout applies its axis values. + let bold_italic = resolve(FAMILY, "Bold Italic"); + assert!(!bold_italic.missing && !bold_italic.synth_bold && !bold_italic.synth_italic); + assert_eq!((bold_italic.face, bold_italic.instance), (black.face, Some(4))); + assert_eq!(wght(&bold_italic), vec![("wght".to_string(), 700.0)]); // The font menu lists every instance, in weight order. let fams = families(); let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1; - assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]); + assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black", "Bold Italic"]); // Static fonts are untouched. assert_eq!(resolve("Inter", "Bold").instance, None); } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 179478517..4c0b5a436 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,34 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { + let h = em * px; + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +603,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -598,20 +629,25 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. /// Margins (left, right) of a paragraph's first or later lines. -fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) { +/// `rtl` is the resolved paragraph direction (an unset direction follows the text). +fn margins_for(para: &ParagraphStyle, rtl: bool, first: bool) -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; - if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } + if rtl { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } } /// The width a line may fill (`None` for point text). -fn avail_for(para: &ParagraphStyle, width: Option, first: bool) -> Option { - let (l, r) = margins_for(para, first); +fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option, first: bool) -> Option { + let (l, r) = margins_for(para, rtl, first); width.map(|w| (w - l - r).max(1.0)) } /// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`. -fn align_for(para: &ParagraphStyle, width: Option, last: bool) -> Align { - if (last || width.is_none()) && para.align == Align::Justify { +fn align_for(para: &ParagraphStyle, rtl: bool, width: Option, last: bool) -> Align { + if rtl && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -622,6 +658,12 @@ fn style_metrics(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size()) } +/// The baseline of a box's first line for style run `si`: the `d` height, else the font ascent. +fn style_first(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: usize) -> f32 { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) +} + /// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run). type CharInfo = (usize, char, char, FaceId, bool, Form, usize); @@ -719,7 +761,7 @@ impl<'a> Para<'a> { } fn line_avail(&self, line: usize) -> f32 { - avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX) + avail_for(self.para, self.base_rtl, self.width, line == 0).unwrap_or(f32::MAX) } /// The byte ranges of the lines, for the wrap width. @@ -736,8 +778,11 @@ impl<'a> Para<'a> { ranges } - /// Minimises the summed squared slack of every line but the last. + /// Minimises the summed squared slack of every line but the last; with Justify All the last + /// line is stretched too, so its slack counts (After Effects then balances the lines: 4 / 4 / 4 + /// words where the other modes break 6 / 6 / 1). fn optimal_breaks(&self, opps: &[usize]) -> Vec> { + let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -757,7 +802,7 @@ impl<'a> Para<'a> { let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -802,8 +847,8 @@ impl<'a> Para<'a> { /// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one. fn place_line(&self, li: usize, r: Range, last: bool) -> ParaLine { - let align = align_for(self.para, self.width, last); - let avail = avail_for(self.para, self.width, li == 0); + let align = align_for(self.para, self.base_rtl, self.width, last); + let avail = avail_for(self.para, self.base_rtl, self.width, li == 0); let (ce, tw) = self.trailing_ws(&r); let hang = self.hang_of(&r, ce); let content = self.range_w(r.start..ce); @@ -819,7 +864,7 @@ impl<'a> Para<'a> { } let placed = self.place_glyphs(&r, space_extra, ce); let carets = self.carets(&r, &placed.extents, placed.pen); - let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs); + let (asc, desc, adv, first) = self.line_metrics(&r, &placed.glyphs); let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content }; ParaLine { range: self.base + r.start..self.base + r.end, @@ -830,10 +875,11 @@ impl<'a> Para<'a> { hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, - margins: margins_for(self.para, li == 0), + margins: margins_for(self.para, self.base_rtl, li == 0), underline: placed.underline, } } @@ -938,8 +984,8 @@ impl<'a> Para<'a> { } /// Ascent, descent and advance of the line: the tallest face and the largest leading on it. - fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32) { - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32, f32) { + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let char_start = self.chars.partition_point(|c| c.0 < r.start); let char_end = self.chars.partition_point(|c| c.0 < r.end); let mut seen: Vec = self.chars[char_start..char_end].iter().map(|c| c.6).collect(); @@ -954,6 +1000,7 @@ impl<'a> Para<'a> { asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(self.runs[si].1.line_advance()); + first = first.max(style_first(self.primaries, self.runs, si)); } for g in glyphs { if g.face != self.primaries[g.run].face { @@ -962,7 +1009,7 @@ impl<'a> Para<'a> { desc = desc.max(m.descent); } } - (asc, desc, adv) + (asc, desc, adv, first) } } @@ -1037,6 +1084,8 @@ fn paragraph( if text.is_empty() { let si = run_at(runs, base); let pm = style_metrics(primaries, runs, si); + // No text to resolve a direction from: the explicit one. + let rtl = para.rtl == Some(true); return vec![ParaLine { range: base..base, glyphs: vec![], @@ -1047,9 +1096,10 @@ fn paragraph( ascent: pm.ascent, descent: pm.descent, advance: runs[si].1.line_advance(), - align: align_for(para, width, true), - avail: avail_for(para, width, true), - margins: margins_for(para, true), + first: style_first(primaries, runs, si), + align: align_for(para, rtl, width, true), + avail: avail_for(para, rtl, width, true), + margins: margins_for(para, rtl, true), underline: None, }]; } @@ -1120,7 +1170,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 9079a1225..5792c1c16 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -357,3 +357,140 @@ fn named_instance_sets_the_base_variations() { let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default()); assert!(inter.glyphs.iter().all(|g| g.variations == 0)); } + +/// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole +/// thousandths of an em) below the box top, at the nominal size also for superscript, whose +/// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the +/// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the +/// superscript glyphs 15.8 px low. +#[test] +fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } +} + +/// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the +/// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. +#[test] +fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } +} + +/// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that +/// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the +/// stretched last line is not a lone word; word positions then match AE to 1 px. +#[test] +fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); +} + +/// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified +/// code (justify last left / centre / right, justify all) sits flush right at its anchor, as +/// right-aligned text does. +#[test] +fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } +} + +/// The same holds when the direction is unset and the text itself resolves right to left. +#[test] +fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); +} + +/// Indents follow the resolved direction too: detected right-to-left text puts its start +/// indent on the right, as an explicit right-to-left paragraph does. +#[test] +fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |rtl: Option| { + let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (explicit, detected) = (at(Some(true)), at(None)); + assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}"); +} + +/// Only the first line's own runs decide whether box text starts at the font ascent: a +/// vertical run further down leaves a horizontal first line on its `d`-height baseline. +#[test] +fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7ecc06..8cc8e3264 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,15 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +361,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 7a64a82bb..9bc625d10 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -198,7 +198,9 @@ impl FrameRate { return r; } } - FrameRate::new((fps * 1000.0).round() as i64, 1000) + let num = (fps * 1000.0).round() as i64; + // A tiny positive rate must not round to the invalid 0/1 rate. + FrameRate::new(if fps > 0.0 { num.max(1) } else { num }, 1000) } pub fn as_f64(self) -> f64 { @@ -387,7 +389,11 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp let rem = Tick(abs.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); format!("{sign}{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) } - TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + TimeDisplay::Seconds => { + let s = t.seconds(); + // A value that rounds to zero must not print a sign ("-0.000"). + format!("{:.3}", if (s * 1000.0).round() == 0.0 { 0.0 } else { s }) + } } } @@ -533,6 +539,21 @@ pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_ mod tests { use super::*; + #[test] + fn from_f64_tiny_positive_fps_is_nonzero() { + let r = FrameRate::from_f64(0.0001); + assert!(r.num > 0 && r.den > 0, "got {r:?}"); + } + + #[test] + fn seconds_display_has_no_negative_zero() { + for t in [Tick(-1), Tick(0), Tick(-100)] { + assert_eq!(format_time(t, FrameRate::FPS_30, false, TimeDisplay::Seconds, 48000), "0.000"); + } + let one = Tick(-TICKS_PER_SECOND); + assert_eq!(format_time(one, FrameRate::FPS_30, false, TimeDisplay::Seconds, 48000), "-1.000"); + } + #[test] fn frame_rate_constructors_accept_extreme_numbers() { assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index e03341d14..a4412dbce 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -11,7 +11,7 @@ use rayon::prelude::*; use super::geometry::Pose; -use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; +use super::linalg::{M3, V3, add, cholesky_solve, conjugate_gradient, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; use super::{Distortion, SolveError}; /// A camera in the adjustment. @@ -216,8 +216,52 @@ fn layout(ncam: usize, o: &BaOpts) -> Layout { Layout { start, fidx, shared, dist, n, rot_only: o.fix_centers } } +// Six pose parameters, one shared OR per-camera focal parameter, two distortion parameters. +const MAX_CAMERA_JACOBIAN_ENTRIES: usize = 6 + 1 + 2; +type CameraJacobianEntry = (usize, [f64; 2]); + +/// Keeps the current parameter set on the stack. Extra parameters spill rather than panic +/// if a future extension grows beyond the inline capacity. +struct CameraJacobian { + entries: [CameraJacobianEntry; MAX_CAMERA_JACOBIAN_ENTRIES], + len: usize, + overflow: Vec, +} + +impl CameraJacobian { + fn new() -> Self { + Self { entries: [(0, [0.0; 2]); MAX_CAMERA_JACOBIAN_ENTRIES], len: 0, overflow: Vec::new() } + } + + fn clear(&mut self) { + self.len = 0; + self.overflow.clear(); + } + + fn push(&mut self, entry: CameraJacobianEntry) { + if self.overflow.is_empty() { + if let Some(slot) = self.entries.get_mut(self.len) { + *slot = entry; + self.len += 1; + return; + } + self.overflow.extend_from_slice(&self.entries); + } + self.overflow.push(entry); + } + + fn as_slice(&self) -> &[CameraJacobianEntry] { + if self.overflow.is_empty() { + // len only grows after a successful get_mut, so it cannot exceed capacity. + self.entries.get(..self.len).unwrap_or_default() + } else { + &self.overflow + } + } +} + /// Camera-side Jacobian entries of one observation: (parameter index, d r / d param). -fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; 2], out: &mut Vec<(usize, [f64; 2])>) { +fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; 2], out: &mut CameraJacobian) { out.clear(); let s = l.start[ci]; if s != usize::MAX { @@ -243,6 +287,101 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +struct PtSys { + v: M3, + bp: V3, + w: Vec<(usize, V3)>, +} + +struct PreconditionerBlock { + start: usize, + dim: usize, + inverse: Vec, +} + +fn camera_preconditioner(u: &[f64], l: &Layout) -> Option> { + let mut ranges = Vec::new(); + let pose_dim = if l.rot_only { 3 } else { 6 }; + for (&pose, &focal) in l.start.iter().zip(&l.fidx) { + if pose != usize::MAX { + ranges.push((pose, pose_dim + usize::from(focal != usize::MAX))); + } else if focal != usize::MAX { + ranges.push((focal, 1)); + } + } + if let Some(start) = l.shared.or(l.dist) { + ranges.push((start, l.n - start)); + } + ranges + .into_iter() + .map(|(start, dim)| { + let mut matrix = Vec::with_capacity(dim * dim); + for i in 0..dim { + for j in 0..dim { + matrix.push(*u.get((start + i) * l.n + start + j)?); + } + } + let mut inverse = vec![0.0; dim * dim]; + for col in 0..dim { + let mut a = matrix.clone(); + let mut b = vec![0.0; dim]; + *b.get_mut(col)? = 1.0; + if !cholesky_solve(&mut a, dim, &mut b) { + return None; + } + for (row, value) in b.into_iter().enumerate() { + inverse[row * dim + col] = value; + } + } + Some(PreconditionerBlock { start, dim, inverse }) + }) + .collect() +} + +/// Apply the point-eliminated system without materialising W V^-1 W^T. +fn iterative_schur(u: &[f64], rhs: &[f64], systems: &[PtSys], vinv: &[Option], l: &Layout) -> Option> { + if l.n == 0 || l.n.checked_mul(l.n) != Some(u.len()) || rhs.len() != l.n || systems.len() != vinv.len() { + return None; + } + let blocks = camera_preconditioner(u, l)?; + // U contains camera blocks, global parameters and the focal prior. Store its nonzeros + // once; all subsequent products are linear in these and the point-camera connections. + let rows: Vec> = + u.chunks_exact(l.n).map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0.0).map(|(i, v)| (i, *v)).collect()).collect(); + let mut reduced_rhs = rhs.to_vec(); + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let vb = mv(vi, ps.bp); + for (i, w) in &ps.w { + *reduced_rhs.get_mut(*i)? -= dot(*w, vb); + } + } + conjugate_gradient( + &reduced_rhs, + |x, y| { + for (i, row) in rows.iter().enumerate() { + y[i] = row.iter().map(|(j, v)| v * x[*j]).sum(); + } + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let q = ps.w.iter().fold([0.0; 3], |q, (i, w)| add(q, scale(*w, x[*i]))); + let q = mv(vi, q); + for (i, w) in &ps.w { + y[*i] -= dot(*w, q); + } + } + }, + |r, z| { + z.fill(0.0); + for block in &blocks { + for i in 0..block.dim { + z[block.start + i] = (0..block.dim).map(|j| block.inverse[i * block.dim + j] * r[block.start + j]).sum(); + } + } + }, + ) +} + /// Smooth per-camera log focal lengths in the normal equations. fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { let n = l.n; @@ -266,10 +405,56 @@ fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc } } +/// Eliminate the points into the camera system explicitly (`S -= W V^-1 W^T`, `rhs -= W V^-1 bp`). +fn reduce_points(systems: &[PtSys], vinv: &[Option], n: usize, s: &mut [f64], rhs: &mut [f64]) { + let npts = systems.len(); + let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { + for j in range { + let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; + let vb = mv(vi, ps.bp); + let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); + for (a, (ia, wa)) in ps.w.iter().enumerate() { + rhs[*ia] -= dot(*wa, vb); + for (ib, wb) in &ps.w[a..] { + let d = dot(wv[a], *wb); + s[ia * n + ib] -= d; + if ia != ib { + s[ib * n + ia] -= d; + } + } + } + } + }; + let chunks = rayon::current_num_threads().clamp(1, 8); + if n <= 1200 && npts >= 64 && chunks > 1 { + let per = npts.div_ceil(chunks); + let parts: Vec<(Vec, Vec)> = (0..chunks) + .into_par_iter() + .map(|t| { + let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); + reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); + (ds, dr) + }) + .collect(); + for (ds, dr) in parts { + s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); + for (x, y) in rhs.iter_mut().zip(dr) { + *x += y; + } + } + } else { + reduce(0..npts, s, rhs); + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. /// Invalid inputs or workspaces exceeding 256 MiB return an error before mutation. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> Result { validate(cams, pts, obs, o)?; + adjust_impl(cams, pts, obs, o, true) +} + +fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, allow_iterative: bool) -> Result { let l = layout(cams.len(), o); let matrix_len = matrix_len(l.n, pts.len(), obs.len())?; let npts = pts.len(); @@ -297,14 +482,9 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> iters += 1; // ---- Normal equations (Gauss–Newton with IRLS weights). // Per point: V (3×3), bp (3), W entries (cam-side index → 3). - struct PtSys { - v: [[f64; 3]; 3], - bp: V3, - w: Vec<(usize, V3)>, - } let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; - let mut jc: Vec<(usize, [f64; 2])> = Vec::with_capacity(8); + let mut jc = CameraJacobian::new(); for &oi in &by_pt[j] { let ob = &obs[oi as usize]; let ci = ob.cam as usize; @@ -329,6 +509,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> jc.push((g, [dk[0][0], dk[1][0]])); jc.push((g + 1, [dk[0][1], dk[1][1]])); } + let jc = jc.as_slice(); // Point Jacobian: A R. let jx = if o.fix_points { [[0.0; 3]; 2] @@ -337,9 +518,9 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let r1 = mtv(&c.pose.r, a[1]); [r0, r1] }; - for (ia, ja) in &jc { + for (ia, ja) in jc.iter() { bc[*ia] -= w * (ja[0] * r[0] + ja[1] * r[1]); - for (ib, jb) in &jc { + for (ib, jb) in jc.iter() { s_acc[ia * n + ib] += w * (ja[0] * jb[0] + ja[1] * jb[1]); } } @@ -350,7 +531,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> ps.v[a1][b1] += w * (jx[0][a1] * jx[0][b1] + jx[1][a1] * jx[1][b1]); } } - for (ia, ja) in &jc { + for (ia, ja) in jc.iter() { let wv = [w * (ja[0] * jx[0][0] + ja[1] * jx[1][0]), w * (ja[0] * jx[0][1] + ja[1] * jx[1][1]), w * (ja[0] * jx[0][2] + ja[1] * jx[1][2])]; // Each observation of a point is on another camera: only the shared focal @@ -400,6 +581,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); + let schur_pairs = systems.iter().fold(0usize, |sum, ps| sum.saturating_add(ps.w.len().saturating_mul(ps.w.len().saturating_add(1)) / 2)); + // Reserve the iterative solve for expensive long-track reductions; small systems + // retain the direct path and its rounding. Failed solves use the direct path too. + let mut use_iterative = allow_iterative && !o.fix_points && n >= 120 && schur_pairs > 4_000_000; // ---- Try steps with increasing damping. let mut accepted = false; for _try in 0..12 { @@ -427,50 +612,26 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> }) .collect() }; - if !o.fix_points { - let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { - for j in range { - let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; - let vb = mv(vi, ps.bp); - let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); - for (a, (ia, wa)) in ps.w.iter().enumerate() { - rhs[*ia] -= dot(*wa, vb); - for (ib, wb) in &ps.w[a..] { - let d = dot(wv[a], *wb); - s[ia * n + ib] -= d; - if ia != ib { - s[ib * n + ia] -= d; - } - } - } - } - }; - let chunks = rayon::current_num_threads().clamp(1, 8); - if n <= 1200 && npts >= 64 && chunks > 1 { - let per = npts.div_ceil(chunks); - let parts: Vec<(Vec, Vec)> = (0..chunks) - .into_par_iter() - .map(|t| { - let (mut ds, mut dr) = (vec![0.0; matrix_len], vec![0.0; n]); - reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); - (ds, dr) - }) - .collect(); - for (ds, dr) in parts { - s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); - for (x, y) in rhs.iter_mut().zip(dr) { - *x += y; - } - } - } else { - reduce(0..npts, &mut s, &mut rhs); + let iterative_step = if use_iterative { iterative_schur(&s, &rhs, &systems, &vinv, &l) } else { None }; + if dbg && use_iterative { + eprintln!("ba iterative n={n} pairs={schur_pairs} solved={}", iterative_step.is_some()); + } + if use_iterative && iterative_step.is_none() { + use_iterative = false; + } + let dc = if let Some(dc) = iterative_step { + dc + } else { + if !o.fix_points { + reduce_points(&systems, &vinv, n, &mut s, &mut rhs); } - } - let mut dc = rhs; - if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { - lambda *= 10.0; - continue; - } + let mut dc = rhs; + if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { + lambda *= 10.0; + continue; + } + dc + }; // Apply. let mut nc: Vec = cams.to_vec(); for (i, c) in nc.iter_mut().enumerate() { @@ -548,6 +709,112 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> mod tests { use super::*; use crate::camtrack::geometry::Rng; + use crate::camtrack::linalg::{I3, log_rot, mt, norm}; + + #[test] + fn long_track_adjustment_matches_direct_reprojections_and_outliers() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (focal, fix_centers, distortion) in + [(FocalMode::Fixed, false, false), (FocalMode::Shared, false, true), (FocalMode::PerCamera(30.0), false, true), (FocalMode::Shared, true, true)] + { + let mut rng = Rng(71); + let mut noise = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5; + let truth: Vec = (0..320).map(|_| [noise() * 8.0, noise() * 5.0, 10.0 + noise() * 5.0]).collect(); + let true_cams: Vec<_> = (0..60) + .map(|i| BaCam { + pose: Pose { + r: rodrigues([0.001 * i as f64, -0.003 * i as f64, 0.0]), + c: if fix_centers { [0.0; 3] } else { [0.03 * i as f64, 0.01 * (i as f64).sin(), 0.0] }, + }, + f: if matches!(focal, FocalMode::PerCamera(_)) { 900.0 * (1.0 + 0.05 * (i as f64 * 0.07).sin()) } else { 900.0 }, + dist: if distortion { Distortion { k1: 0.015, k2: -0.005, radius: 1100.0 } } else { Distortion::NONE }, + }) + .collect(); + let mut obs = Vec::new(); + for (ci, c) in true_cams.iter().enumerate() { + for (pi, p) in truth.iter().enumerate() { + let mut uv = residual(c, *p, [0.0; 2]).unwrap(); + uv[0] += noise() * 0.05; + uv[1] += noise() * 0.05; + if (ci * truth.len() + pi).is_multiple_of(37) { + uv[0] += 25.0; + uv[1] -= 30.0; + } + obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv }); + } + } + let mut cams = true_cams.clone(); + for c in cams.iter_mut().skip(2) { + c.pose.r = mm(&rodrigues([0.001, -0.001, 0.0005]), &c.pose.r); + if !fix_centers { + c.pose.c = add(c.pose.c, [0.002, -0.003, 0.001]); + } + } + let mut pts: Vec<_> = truth.iter().map(|p| add(*p, [0.01, -0.01, 0.005])).collect(); + let (mut direct_cams, mut direct_pts) = (cams.clone(), pts.clone()); + let opts = BaOpts { focal, fix_centers, distortion, fixed: (0..60).map(|i| i < 2).collect(), max_iter: 10, ..Default::default() }; + let report = adjust(&mut cams, &mut pts, &obs, &opts).unwrap(); + let direct = adjust_impl(&mut direct_cams, &mut direct_pts, &obs, &opts, false).unwrap(); + assert!((report.cost - direct.cost).abs() < 1e-5 * direct.cost.max(1.0)); + for ob in &obs { + let a = residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv).unwrap(); + let b = residual(&direct_cams[ob.cam as usize], direct_pts[ob.pt as usize], ob.uv).unwrap(); + assert!((a[0] - b[0]).hypot(a[1] - b[1]) < 0.001); + assert_eq!(a[0].hypot(a[1]) <= 4.0, b[0].hypot(b[1]) <= 4.0); + } + for (a, b) in cams.iter().zip(direct_cams) { + assert!(norm(sub(a.pose.c, b.pose.c)) < 0.001); + assert!(norm(log_rot(&mm(&a.pose.r, &mt(&b.pose.r)))) < 1e-4); + assert!((a.f - b.f).abs() < 1e-4 * b.f); + assert!((a.dist.k1 - b.dist.k1).abs() < 1e-4 && (a.dist.k2 - b.dist.k2).abs() < 1e-4); + } + } + }); + } + + #[test] + fn matrix_free_schur_matches_explicit_system_and_rejects_bad_preconditioner() { + let l = layout(20, &BaOpts::default()); + let n = l.n; + let mut u = vec![0.0; n * n]; + for i in 0..n { + u[i * n + i] = 10.0; + } + let systems: Vec<_> = (0..80) + .map(|j| PtSys { + v: I3, + bp: [0.01, -0.02, 0.03], + w: (0..n).map(|i| (i, [((j * 13 + i * 7) % 19) as f64 * 0.001 - 0.009, (i % 7) as f64 * 0.001, (j % 11) as f64 * 0.001])).collect(), + }) + .collect(); + let vinv = vec![Some(I3); systems.len()]; + let rhs: Vec<_> = (0..n).map(|i| (i % 7) as f64 - 3.0).collect(); + let x = iterative_schur(&u, &rhs, &systems, &vinv, &l).unwrap(); + let mut explicit = u.clone(); + let mut reduced_rhs = rhs.clone(); + for ps in &systems { + for (i, a) in &ps.w { + reduced_rhs[*i] -= dot(*a, ps.bp); + for (j, b) in &ps.w { + explicit[i * n + j] -= dot(*a, *b); + } + } + } + let mut direct = reduced_rhs.clone(); + let mut factor = explicit.clone(); + assert!(cholesky_solve(&mut factor, n, &mut direct)); + for (a, b) in x.iter().zip(direct) { + assert!((a - b).abs() < 1e-9); + } + let mut error2 = 0.0; + for (i, row) in explicit.chunks_exact(n).enumerate() { + let ax: f64 = row.iter().zip(&x).map(|(a, x)| a * x).sum(); + error2 += (ax - reduced_rhs[i]).powi(2); + } + assert!(error2 < 1e-18 * reduced_rhs.iter().map(|v| v * v).sum::()); + u[0] = -1.0; + assert!(iterative_schur(&u, &rhs, &systems, &vinv, &l).is_none()); + } fn test_camera() -> BaCam { BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE } @@ -618,6 +885,26 @@ mod tests { assert!(matrix_len(955, 900, 160 * 900).is_ok()); } + #[test] + fn camera_jacobian_keeps_extra_parameters_and_resets() { + let mut jac = CameraJacobian::new(); + let count = MAX_CAMERA_JACOBIAN_ENTRIES + 3; + for i in 0..count { + jac.push((i, [i as f64, -(i as f64)])); + } + assert_eq!(jac.as_slice().iter().count(), count); + for (i, entry) in jac.as_slice().iter().enumerate() { + assert_eq!(*entry, (i, [i as f64, -(i as f64)])); + } + jac.clear(); + assert_eq!(jac.as_slice().iter().count(), 0); + for i in 0..MAX_CAMERA_JACOBIAN_ENTRIES { + jac.push((i, [1.0, 2.0])); + } + assert_eq!(jac.as_slice().iter().count(), MAX_CAMERA_JACOBIAN_ENTRIES); + assert!(jac.overflow.is_empty()); + } + #[test] fn bundle_adjustment_converges_from_noise() { let mut rng = Rng(99); diff --git a/crates/track/src/camtrack/features.rs b/crates/track/src/camtrack/features.rs index 1775a304f..246febda4 100644 --- a/crates/track/src/camtrack/features.rs +++ b/crates/track/src/camtrack/features.rs @@ -61,15 +61,24 @@ impl TrackAnalyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { - let (max_side, want) = self.params(); + let (max_side, _) = self.params(); let factor = analysis_factor(frame.img.width, frame.img.height, max_side); - self.factor = factor as f64; let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 4); + let prev = self.prev.take(); + self.push_pyramid(prev.as_ref(), &pyr); + self.prev = Some(pyr); + } + + /// Track using caller-owned consecutive pyramids. The caller retains the full pyramid + /// for the next frame; standalone camera analysis uses the same path through `push`. + pub(crate) fn push_pyramid(&mut self, prev: Option<&GrayPyramid>, pyr: &GrayPyramid) { + let (_, want) = self.params(); + self.factor = pyr.factor; let k = self.frame; - match &self.prev { + match prev { Some(prev) if prev.width() == pyr.width() && prev.height() == pyr.height() && prev.offset == pyr.offset => { let pts: Vec<[f64; 2]> = self.active.iter().map(|a| a.1).collect(); - let q = track(prev, &pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); + let q = track(prev, pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); let mut next = Vec::with_capacity(self.active.len()); for ((ti, _), q) in self.active.iter().zip(q) { if let Some(q) = q { @@ -101,14 +110,13 @@ impl TrackAnalyzer { !occ.get(cy * gw + cx).copied().unwrap_or(true) }; let opts = CornerOpts { max_features: want - self.active.len().min(want), min_distance: md, quality: 0.001, window: 2, border: 8 }; - for p in good_features(&pyr, &opts, Some(&free)) { + for p in good_features(pyr, &opts, Some(&free)) { let l = pyr.to_layer(p); let id = self.tracks.len() as u32; self.tracks.push(Track2D { id, start: k, pts: vec![[l[0] as f32, l[1] as f32]] }); self.active.push((id as usize, p)); } } - self.prev = Some(pyr); self.frame += 1; } diff --git a/crates/track/src/camtrack/geometry.rs b/crates/track/src/camtrack/geometry.rs index cc633a9c6..3ce32a397 100644 --- a/crates/track/src/camtrack/geometry.rs +++ b/crates/track/src/camtrack/geometry.rs @@ -207,8 +207,8 @@ pub fn triangulate(views: &[(Pose, [f64; 2])]) -> Option { for (p, x) in views { let t = p.t(); let rows = [[p.r[0][0], p.r[0][1], p.r[0][2], t[0]], [p.r[1][0], p.r[1][1], p.r[1][2], t[1]], [p.r[2][0], p.r[2][1], p.r[2][2], t[2]]]; - let a0: Vec = (0..4).map(|j| x[0] * rows[2][j] - rows[0][j]).collect(); - let a1: Vec = (0..4).map(|j| x[1] * rows[2][j] - rows[1][j]).collect(); + let a0 = [0, 1, 2, 3].map(|j| x[0] * rows[2][j] - rows[0][j]); + let a1 = [0, 1, 2, 3].map(|j| x[1] * rows[2][j] - rows[1][j]); for a in [a0, a1] { let nrm = a.iter().map(|v| v * v).sum::().sqrt().max(1e-300); for i in 0..4 { diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 2efc14556..71e88e109 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -145,6 +145,33 @@ pub fn slerp(a: &M3, b: &M3, t: f64) -> M3 { /// `max(m, n) × n` (rows beyond `m` belong to zero padding), `s` is sorted descending and `V` /// is `n × n` (columns are the right singular vectors). pub fn svd(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { + let rows = m.max(n); + let (mut u, mut s, v) = jacobi_factors(a, m, n); + for (j, sj) in s.iter().enumerate() { + if *sj > 1e-300 { + for r in 0..rows { + u[r * n + j] /= sj; + } + } + } + // Sort descending. + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| s[b].total_cmp(&s[a])); + let (mut u2, mut v2) = (vec![0.0; rows * n], vec![0.0; n * n]); + for (k, &j) in order.iter().enumerate() { + for r in 0..rows { + u2[r * n + k] = u[r * n + j]; + } + for r in 0..n { + v2[r * n + k] = v[r * n + j]; + } + } + s = order.iter().map(|&j| s[j]).collect(); + (u2, s, v2) +} + +// Unnormalised and unsorted factors; full SVD and null-space queries share every sweep. +fn jacobi_factors(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { let rows = m.max(n); let mut u = vec![0.0; rows * n]; u[..m * n].copy_from_slice(&a[..m * n]); @@ -192,28 +219,8 @@ pub fn svd(a: &[f64], m: usize, n: usize) -> (Vec, Vec, Vec) { break; } } - let mut s: Vec = (0..n).map(|j| (0..rows).map(|r| u[r * n + j] * u[r * n + j]).sum::().sqrt()).collect(); - for (j, sj) in s.iter().enumerate() { - if *sj > 1e-300 { - for r in 0..rows { - u[r * n + j] /= sj; - } - } - } - // Sort descending. - let mut order: Vec = (0..n).collect(); - order.sort_by(|&a, &b| s[b].total_cmp(&s[a])); - let (mut u2, mut v2) = (vec![0.0; rows * n], vec![0.0; n * n]); - for (k, &j) in order.iter().enumerate() { - for r in 0..rows { - u2[r * n + k] = u[r * n + j]; - } - for r in 0..n { - v2[r * n + k] = v[r * n + j]; - } - } - s = order.iter().map(|&j| s[j]).collect(); - (u2, s, v2) + let s: Vec = (0..n).map(|j| (0..rows).map(|r| u[r * n + j] * u[r * n + j]).sum::().sqrt()).collect(); + (u, s, v) } /// SVD of a 3 × 3 matrix: (U, singular values descending, V) with `m = U diag(s) Vᵀ`. @@ -235,8 +242,10 @@ pub fn svd3(m: &M3) -> (M3, V3, M3) { /// The unit vector `x` minimising `|A x|` (`m × n` row-major): the right singular vector of /// the smallest singular value. pub fn null_vector(a: &[f64], m: usize, n: usize) -> Vec { - let (_, _, v) = svd(a, m, n); - (0..n).map(|r| v[r * n + n - 1]).collect() + let (_, s, v) = jacobi_factors(a, m, n); + // The full SVD uses a stable descending sort: the last of equal minima wins. + let smallest = (0..n).min_by(|&i, &j| s[i].total_cmp(&s[j]).then_with(|| j.cmp(&i))).unwrap_or(0); + (0..n).map(|r| v[r * n + smallest]).collect() } /// Eigen-decomposition of a symmetric `n × n` matrix (cyclic Jacobi): eigenvalues ascending @@ -317,9 +326,29 @@ fn dot_n(a: &[f64], b: &[f64]) -> f64 { /// Solve `A x = b` for a symmetric positive definite `A` (`n × n`, row-major) in place by /// Cholesky factorisation. Returns false when `A` is not positive definite. pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { + if n.checked_mul(n) != Some(a.len()) || b.len() != n { + return false; + } + let mut band = n.saturating_sub(1); + if b.iter().all(|v| v.is_finite()) { + band = 0; + 'bandwidth: for i in (0..n).rev() { + for j in 0..i.saturating_sub(band) { + if a[i * n + j] != 0.0 { + band = i - j; + // Wide systems (including shared global parameters) use the dense path. + if band >= n / 2 { + band = n.saturating_sub(1); + break 'bandwidth; + } + break; + } + } + } + } for j in 0..n { let mut d = a[j * n + j]; - for k in 0..j { + for k in j.saturating_sub(band)..j { d -= a[j * n + k] * a[j * n + k]; } if d <= 0.0 || !d.is_finite() { @@ -330,14 +359,24 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { let (head, tail) = a.split_at_mut((j + 1) * n); let rowj = &head[j * n..j * n + j]; // Rows below j (sequential: per-column parallel dispatch costs more than it saves). - for row in tail.chunks_mut(n) { - row[j] = (row[j] - dot_n(&row[..j], rowj)) / d; + for (off, row) in tail.chunks_mut(n).take(band).enumerate() { + // Removing full groups of four zero products preserves dot_n's lane order. + let start = (j + 1 + off).saturating_sub(band) / 4 * 4; + row[j] = (row[j] - dot_n(&row[start..j], &rowj[start..])) / d; } } // Forward: L y = b. for i in 0..n { let mut s = b[i]; - for k in 0..i { + let start = i.saturating_sub(band); + // Subtracting signed-zero products can change -0.0 to +0.0. Preserve those + // operations when the accumulator is zero; nonzero values are unaffected. + if s == 0.0 { + for k in 0..start { + s -= a[i * n + k] * b[k]; + } + } + for k in start..i { s -= a[i * n + k] * b[k]; } b[i] = s / a[i * n + i]; @@ -345,14 +384,74 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { // Back: Lᵀ x = y. for i in (0..n).rev() { let mut s = b[i]; - for k in i + 1..n { + let end = i.saturating_add(band).saturating_add(1).min(n); + for k in i + 1..end { s -= a[k * n + i] * b[k]; } + if s == 0.0 { + for k in end..n { + s -= a[k * n + i] * b[k]; + } + } b[i] = s / a[i * n + i]; } b.iter().all(|v| v.is_finite()) } +/// Preconditioned conjugate gradients with an independently recomputed residual check. +/// Failure to converge (or non-finite/non-positive curvature) requests the direct fallback. +pub(crate) fn conjugate_gradient(rhs: &[f64], apply: impl Fn(&[f64], &mut [f64]), precondition: impl Fn(&[f64], &mut [f64])) -> Option> { + let n = rhs.len(); + let norm2 = dot_n(rhs, rhs); + if !norm2.is_finite() { + return None; + } + if rhs.iter().all(|v| *v == 0.0) { + return Some(vec![0.0; n]); + } + let tolerance2 = norm2 * 1e-22; + if tolerance2 == 0.0 { + return None; + } + let mut x = vec![0.0; n]; + let mut r = rhs.to_vec(); + let mut z = vec![0.0; n]; + let mut ap = vec![0.0; n]; + precondition(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot_n(&r, &z); + for _ in 0..n.saturating_mul(2).min(512) { + if !rz.is_finite() || rz <= 0.0 { + return None; + } + apply(&p, &mut ap); + let curvature = dot_n(&p, &ap); + if !curvature.is_finite() || curvature <= 0.0 { + return None; + } + let alpha = rz / curvature; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + if dot_n(&r, &r) <= tolerance2 { + apply(&x, &mut ap); + for i in 0..n { + ap[i] = rhs[i] - ap[i]; + } + return (x.iter().all(|v| v.is_finite()) && dot_n(&ap, &ap) <= 100.0 * tolerance2).then_some(x); + } + precondition(&r, &mut z); + let next_rz = dot_n(&r, &z); + let beta = next_rz / rz; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + rz = next_rz; + } + None +} + /// A similarity transform `x ↦ s R x + t`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Similarity { @@ -428,6 +527,143 @@ pub fn from_euler_xyz(e: V3) -> M3 { mod tests { use super::*; + fn dense_cholesky_reference(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { + for j in 0..n { + let mut d = a[j * n + j]; + for k in 0..j { + d -= a[j * n + k] * a[j * n + k]; + } + if d <= 0.0 || !d.is_finite() { + return false; + } + let d = d.sqrt(); + a[j * n + j] = d; + let (head, tail) = a.split_at_mut((j + 1) * n); + let rowj = &head[j * n..j * n + j]; + // Rows below j (sequential: per-column parallel dispatch costs more than it saves). + for row in tail.chunks_mut(n) { + row[j] = (row[j] - dot_n(&row[..j], rowj)) / d; + } + } + // Forward: L y = b. + for i in 0..n { + let mut s = b[i]; + for k in 0..i { + s -= a[i * n + k] * b[k]; + } + b[i] = s / a[i * n + i]; + } + // Back: Lᵀ x = y. + for i in (0..n).rev() { + let mut s = b[i]; + for k in i + 1..n { + s -= a[k * n + i] * b[k]; + } + b[i] = s / a[i * n + i]; + } + b.iter().all(|v| v.is_finite()) + } + + #[test] + fn banded_cholesky_is_bit_identical_to_dense() { + for n in [1usize, 2, 3, 4, 5, 6, 7, 8, 13, 31, 65, 127] { + for width in [0usize, 1, 2, 5, 11, n - 1] { + for seed in 0..3 { + for scale in [1.0, 1e-100, 1e100] { + let mut a = vec![0.0; n * n]; + for i in 0..n { + for j in 0..i { + let value = if i - j <= width { + (((i * 13 + j * 17 + seed) % 23) as f64 - 11.0) / 128.0 + } else if (i + j) % 2 == 0 { + -0.0 + } else { + 0.0 + }; + a[i * n + j] = value; + a[j * n + i] = value; + } + } + for i in 0..n { + a[i * n + i] = a[i * n..(i + 1) * n].iter().map(|v| v.abs()).sum::() + 1.0; + } + for value in &mut a { + *value *= scale; + } + let mut rhs: Vec = (0..n).map(|i| if i % 7 == 0 { -0.0 } else { ((i * 5 + seed) % 17) as f64 - 8.0 }).collect(); + let (mut reference_a, mut reference_rhs) = (a.clone(), rhs.clone()); + assert!(dense_cholesky_reference(&mut reference_a, n, &mut reference_rhs)); + assert!(cholesky_solve(&mut a, n, &mut rhs)); + assert_eq!( + rhs.iter().map(|v| v.to_bits()).collect::>(), + reference_rhs.iter().map(|v| v.to_bits()).collect::>(), + "n={n} width={width} seed={seed} scale={scale}" + ); + assert_eq!( + a.iter().map(|v| v.to_bits()).collect::>(), + reference_a.iter().map(|v| v.to_bits()).collect::>(), + "factor n={n} width={width}" + ); + } + } + } + } + } + + #[test] + fn banded_cholesky_preserves_underflow_and_signed_zero() { + let n = 8; + let mut a = vec![0.0; n * n]; + for i in 0..n { + for j in 0..n { + a[i * n + j] = if i == j { + 1e300 + } else if (i + j) % 2 == 0 { + -0.0 + } else { + 0.0 + }; + } + } + let mut rhs = vec![-1e-300, 1e-300, -0.0, 0.0, -1e-300, 1e-300, 0.0, -0.0]; + let (mut original, mut expected) = (a.clone(), rhs.clone()); + assert!(dense_cholesky_reference(&mut original, n, &mut expected)); + assert!(cholesky_solve(&mut a, n, &mut rhs)); + assert_eq!(rhs.iter().map(|v| v.to_bits()).collect::>(), expected.iter().map(|v| v.to_bits()).collect::>()); + } + + #[test] + fn cholesky_rejects_invalid_dimensions_without_indexing() { + assert!(!cholesky_solve(&mut [], usize::MAX, &mut [])); + assert!(!cholesky_solve(&mut [1.0], 2, &mut [1.0, 1.0])); + assert!(!cholesky_solve(&mut [1.0], 1, &mut [])); + assert!(cholesky_solve(&mut [], 0, &mut [])); + } + + #[test] + fn conjugate_gradient_checks_residual_and_bad_curvature() { + let n = 64; + let rhs: Vec = (0..n).map(|i| (i % 11) as f64 - 5.0).collect(); + let apply = |x: &[f64], y: &mut [f64]| { + for i in 0..n { + y[i] = 4.0 * x[i] + if i > 0 { x[i - 1] } else { 0.0 } + if i + 1 < n { x[i + 1] } else { 0.0 }; + } + }; + let x = conjugate_gradient(&rhs, apply, |r, z| { + for (r, z) in r.iter().zip(z) { + *z = *r / 4.0; + } + }) + .unwrap(); + let mut product = vec![0.0; n]; + apply(&x, &mut product); + let residual: f64 = rhs.iter().zip(product).map(|(b, ax)| (b - ax).powi(2)).sum(); + assert!(residual < 1e-18 * dot_n(&rhs, &rhs)); + assert!(conjugate_gradient(&[1.0], |x, y| y[0] = -x[0], |r, z| z.copy_from_slice(r)).is_none()); + assert!(conjugate_gradient(&[f64::NAN], |_, _| {}, |_, _| {}).is_none()); + assert_eq!(conjugate_gradient(&[0.0], |_, _| {}, |_, _| {}).unwrap(), vec![0.0]); + } + #[test] fn svd_reconstructs() { let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0]; @@ -441,6 +677,20 @@ mod tests { assert!(s[0] >= s[1] && s[1] >= s[2]); } + #[test] + fn null_vector_matches_full_svd_bits_including_ties() { + for (m, n) in [(0, 0), (1, 1), (3, 3), (8, 9), (9, 9), (20, 3), (100, 9)] { + for scale in [0.0, 1e-100, 1.0, 1e100] { + let a: Vec = (0..m * n).map(|i| (((i * 37) % 31) as f64 - 15.0) * scale).collect(); + let (_, _, v) = svd(&a, m, n); + let expected: Vec<_> = (0..n).map(|r| v[r * n + n - 1].to_bits()).collect(); + assert_eq!(null_vector(&a, m, n).iter().map(|v| v.to_bits()).collect::>(), expected); + } + } + let a = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + assert_eq!(null_vector(&a, 3, 3), vec![0.0, 0.0, 1.0]); + } + #[test] fn rotations_round_trip() { let w = [0.3, -1.2, 0.7]; diff --git a/crates/track/src/klt.rs b/crates/track/src/klt.rs index a4566c79a..c947bd4d1 100644 --- a/crates/track/src/klt.rs +++ b/crates/track/src/klt.rs @@ -254,6 +254,42 @@ impl Default for LkOpts { } } +/// Bilinear taps shared by every pixel of a `(2r+1)²` window centred at `c`: the fractional +/// offset is the same for the whole window, and the column/row indices are clamped like +/// [`Plane::at`] (edges repeated). Row indices are pre-multiplied by the plane width. +#[derive(Default)] +struct Taps { + xs: Vec<[usize; 2]>, + ys: Vec<[usize; 2]>, + t: [f32; 2], +} + +impl Taps { + fn set(&mut self, p: &Plane, c: [f64; 2], r: i32) { + debug_assert_eq!(p.ch, 1); + let (fx, fy) = (c[0] - 0.5, c[1] - 0.5); + let (x0, y0) = (fx.floor(), fy.floor()); + self.t = [(fx - x0) as f32, (fy - y0) as f32]; + const FAR: f64 = (1u64 << 40) as f64; + let (xi, yi) = (x0.clamp(-FAR, FAR) as isize, y0.clamp(-FAR, FAR) as isize); + let (wm, hm) = (p.w as isize - 1, p.h as isize - 1); + let r = r as isize; + self.xs.clear(); + self.ys.clear(); + self.xs.extend((-r..=r).map(|d| [(xi + d).clamp(0, wm) as usize, (xi + d + 1).clamp(0, wm) as usize])); + self.ys.extend((-r..=r).map(|d| [(yi + d).clamp(0, hm) as usize * p.w, (yi + d + 1).clamp(0, hm) as usize * p.w])); + } +} + +/// [`Plane::sample`]'s bilinear form on pre-resolved taps. +#[inline(always)] +fn bilerp(d: &[f32], x: [usize; 2], y: [usize; 2], t: [f32; 2]) -> f32 { + let (a, b, c, e) = (d[y[0] + x[0]], d[y[0] + x[1]], d[y[1] + x[0]], d[y[1] + x[1]]); + let top = a + (b - a) * t[0]; + let bot = c + (e - c) * t[0]; + top + (bot - top) * t[1] +} + fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> { let top = a.levels.len().min(b.levels.len()).checked_sub(1)?; let r = o.radius; @@ -263,7 +299,7 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize]; let mut wgx = win.clone(); let mut wgy = win.clone(); - let mut v1 = [0.0f32]; + let mut taps = Taps::default(); for l in (0..=top).rev() { let s = (1u32 << l) as f64; let (ia, ib) = (&a.levels[l], &b.levels[l]); @@ -274,15 +310,12 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & } let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64); let mut k = 0; - for dy in -r..=r { - for dx in -r..=r { - let (x, y) = (c[0] + dx as f64, c[1] + dy as f64); - ia.sample(x, y, &mut v1); - win[k] = v1[0]; - gx.sample(x, y, &mut v1); - wgx[k] = v1[0]; - gy.sample(x, y, &mut v1); - wgy[k] = v1[0]; + taps.set(ia, c, r); + for &y in &taps.ys { + for &x in &taps.xs { + win[k] = bilerp(&ia.data, x, y, taps.t); + wgx[k] = bilerp(&gx.data, x, y, taps.t); + wgy[k] = bilerp(&gy.data, x, y, taps.t); let (ix, iy) = (wgx[k] as f64, wgy[k] as f64); gxx += ix * ix; gxy += ix * iy; @@ -300,10 +333,10 @@ fn lk_point(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: & for _ in 0..o.iterations { let (mut bx, mut by) = (0.0f64, 0.0f64); let mut k = 0; - for dy in -r..=r { - for dx in -r..=r { - ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1); - let diff = (win[k] - v1[0]) as f64; + taps.set(ib, [c[0] + g[0] + v[0], c[1] + g[1] + v[1]], r); + for &y in &taps.ys { + for &x in &taps.xs { + let diff = (win[k] - bilerp(&ib.data, x, y, taps.t)) as f64; bx += diff * wgx[k] as f64; by += diff * wgy[k] as f64; k += 1; @@ -397,6 +430,87 @@ mod tests { im } + /// The per-pixel `Plane::sample` implementation `lk_point` replaced, kept as the oracle. + fn lk_point_ref(a: &GrayPyramid, b: &GrayPyramid, p: [f64; 2], guess: [f64; 2], o: &LkOpts) -> Option<[f64; 2]> { + let top = a.levels.len().min(b.levels.len()).checked_sub(1)?; + let r = o.radius; + let n = ((2 * r + 1) * (2 * r + 1)) as f64; + let s0 = (1u32 << top) as f64; + let mut g = [guess[0] / s0, guess[1] / s0]; + let mut win = vec![0.0f32; ((2 * r + 1) * (2 * r + 1)) as usize]; + let mut wgx = win.clone(); + let mut wgy = win.clone(); + let mut v1 = [0.0f32]; + for l in (0..=top).rev() { + let s = (1u32 << l) as f64; + let (ia, ib) = (&a.levels[l], &b.levels[l]); + let (gx, gy) = &a.grads[l]; + let c = [p[0] / s, p[1] / s]; + if c[0] < -1.0 || c[1] < -1.0 || c[0] > ia.w as f64 + 1.0 || c[1] > ia.h as f64 + 1.0 { + return None; + } + let (mut gxx, mut gxy, mut gyy) = (0.0f64, 0.0f64, 0.0f64); + let mut k = 0; + for dy in -r..=r { + for dx in -r..=r { + let (x, y) = (c[0] + dx as f64, c[1] + dy as f64); + ia.sample(x, y, &mut v1); + win[k] = v1[0]; + gx.sample(x, y, &mut v1); + wgx[k] = v1[0]; + gy.sample(x, y, &mut v1); + wgy[k] = v1[0]; + let (ix, iy) = (wgx[k] as f64, wgy[k] as f64); + gxx += ix * ix; + gxy += ix * iy; + gyy += iy * iy; + k += 1; + } + } + let det = gxx * gyy - gxy * gxy; + let tr = 0.5 * (gxx + gyy); + let mineig = (tr - (0.25 * (gxx - gyy).powi(2) + gxy * gxy).sqrt()) / n; + if mineig < o.min_eigen || det.abs() < 1e-18 { + return None; + } + let mut v = [0.0f64; 2]; + for _ in 0..o.iterations { + let (mut bx, mut by) = (0.0f64, 0.0f64); + let mut k = 0; + for dy in -r..=r { + for dx in -r..=r { + ib.sample(c[0] + g[0] + v[0] + dx as f64, c[1] + g[1] + v[1] + dy as f64, &mut v1); + let diff = (win[k] - v1[0]) as f64; + bx += diff * wgx[k] as f64; + by += diff * wgy[k] as f64; + k += 1; + } + } + let nx = (gyy * bx - gxy * by) / det; + let ny = (gxx * by - gxy * bx) / det; + v[0] += nx; + v[1] += ny; + if !(v[0].is_finite() && v[1].is_finite()) { + return None; + } + if nx * nx + ny * ny < o.epsilon * o.epsilon { + break; + } + } + if l > 0 { + g = [2.0 * (g[0] + v[0]), 2.0 * (g[1] + v[1])]; + } else { + g = [g[0] + v[0], g[1] + v[1]]; + } + } + let q = [p[0] + g[0], p[1] + g[1]]; + let l0 = &b.levels[0]; + if q[0] < 0.0 || q[1] < 0.0 || q[0] > l0.w as f64 || q[1] > l0.h as f64 { + return None; + } + Some(q) + } + #[test] fn klt_recovers_a_shift() { let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3); @@ -411,6 +525,35 @@ mod tests { assert!(errs[errs.len() / 2] < 0.1, "median error {}", errs[errs.len() / 2]); } + #[test] + fn window_taps_match_per_pixel_sampling_bit_for_bit() { + let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 3); + let b = GrayPyramid::from_image(&img(6.3, -4.2), [0.0; 2], 1, 3); + let mut pts = vec![]; + for y in (-3..125).step_by(3) { + for x in (-3..165).step_by(3) { + pts.push([x as f64 + 0.37 * ((x * 7 + y) % 3) as f64, y as f64 + 0.5 * ((x + y * 5) % 2) as f64]); + } + } + pts.extend([[0.0, 0.0], [160.0, 120.0], [80.0, 60.0], [0.5, 0.5], [159.5, 119.5]]); + let (mut tracked, mut lost) = (0, 0); + for (i, p) in pts.iter().enumerate() { + for o in [LkOpts::default(), LkOpts { radius: 3, iterations: 5, ..Default::default() }] { + let g = [(i % 5) as f64 - 2.0, (i % 3) as f64 - 1.0]; + let (new, old) = (lk_point(&a, &b, *p, g, &o), lk_point_ref(&a, &b, *p, g, &o)); + match (new, old) { + (Some(n), Some(o)) => { + assert_eq!([n[0].to_bits(), n[1].to_bits()], [o[0].to_bits(), o[1].to_bits()], "{p:?}"); + tracked += 1; + } + (None, None) => lost += 1, + (n, o) => panic!("{p:?}: {n:?} vs {o:?}"), + } + } + } + assert!(tracked > 500 && lost > 0, "{tracked} tracked, {lost} lost"); + } + #[test] fn features_respect_the_region_and_spacing() { let a = GrayPyramid::from_image(&img(0.0, 0.0), [0.0; 2], 1, 2); diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index fe36bc096..8867bfcb8 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -201,7 +201,7 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 { } let ch = p.ch; let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64); - let identity = (a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9; + let identity = is_identity(a); let fx = c[0] - 0.5; let fy = c[1] - 0.5; let (bx, by) = (fx.floor(), fy.floor()); @@ -256,12 +256,114 @@ fn ncc(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> f64 { (st / (var * p.ss).sqrt()).clamp(-1.0, 1.0) } +fn is_identity(a: [f64; 2]) -> bool { + (a[0] - 1.0).abs() < 1e-9 && a[1].abs() < 1e-9 +} + +/// The bilinear taps of every template pixel at `c + A u`. Candidates `c + (i, j)` with integer +/// `(i, j)` give each pixel the same fractional offsets, so one table serves them all and a +/// candidate is integer-indexed lerps (the arithmetic of `Plane::sample`) with one bounds check. +struct Taps { + /// Offset (in samples) of the top-left tap per template pixel from the one at `base`. + off: Vec, + w: Vec<[f32; 2]>, + base: isize, + /// Tap bounding box (x0, y0, x1, y1) in plane pixels at the reference, `+1` taps included. + bb: [isize; 4], +} + +impl Taps { + fn new(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> Option { + let n = ((2 * p.r[0] + 1) * (2 * p.r[1] + 1)) as usize; + let (mut off, mut w) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let (rx, ry) = ((c[0] - 0.5).floor(), (c[1] - 0.5).floor()); + if !(rx.abs() < 1e9 && ry.abs() < 1e9) { + return None; + } + let (rx, ry) = (rx as isize, ry as isize); + let mut bb = [isize::MAX, isize::MAX, isize::MIN, isize::MIN]; + for gy in -p.r[1]..=p.r[1] { + for gx in -p.r[0]..=p.r[0] { + let (ux, uy) = (gx as f64, gy as f64); + let fx = c[0] + a[0] * ux - a[1] * uy - 0.5; + let fy = c[1] + a[1] * ux + a[0] * uy - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + if !(x0.abs() < 1e9 && y0.abs() < 1e9) { + return None; + } + let (dx, dy) = (x0 as isize - rx, y0 as isize - ry); + bb = [bb[0].min(dx), bb[1].min(dy), bb[2].max(dx + 1), bb[3].max(dy + 1)]; + off.push((dy * plane.w as isize + dx) * plane.ch as isize); + w.push([(fx - x0) as f32, (fy - y0) as f32]); + } + } + let base = (ry * plane.w as isize + rx) * plane.ch as isize; + Some(Taps { off, w, base, bb: [bb[0] + rx, bb[1] + ry, bb[2] + rx, bb[3] + ry] }) + } + + /// The correlation at `c + (i, j)`, or `None` when a tap leaves the plane. + fn ncc(&self, plane: &Plane, p: &Patch, i: isize, j: isize) -> Option { + if self.bb[0] + i < 0 || self.bb[1] + j < 0 || self.bb[2] + i >= plane.w as isize || self.bb[3] + j >= plane.h as isize { + return None; + } + let ch = p.ch; + let row = plane.w * ch; + let b = self.base + (j * plane.w as isize + i) * ch as isize; + let d = &plane.data; + let (mut s, mut s2, mut st) = (0.0f64, 0.0f64, 0.0f64); + let mut k = 0; + for (o, t) in self.off.iter().zip(&self.w) { + let i00 = (b + o) as usize; + let (i10, i01) = (i00 + ch, i00 + row); + for cc in 0..ch { + let top = d[i00 + cc] + (d[i10 + cc] - d[i00 + cc]) * t[0]; + let bot = d[i01 + cc] + (d[i01 + ch + cc] - d[i01 + cc]) * t[0]; + let x = (top + (bot - top) * t[1]) as f64; + s += x; + s2 += x * x; + st += x * p.zm[k] as f64; + k += 1; + } + } + let n = p.zm.len() as f64; + let var = s2 - s * s / n; + if var <= 1e-12 { + return Some(0.0); + } + Some((st / (var * p.ss).sqrt()).clamp(-1.0, 1.0)) + } +} + +/// `ncc` of one template at integer offsets from a reference centre, through a [`Taps`] table +/// when the shape is not the identity (the identity has its own fast path in `ncc`). +struct Scorer<'a> { + plane: &'a Plane, + p: &'a Patch, + c: [f64; 2], + a: [f64; 2], + taps: Option, +} + +impl<'a> Scorer<'a> { + fn new(plane: &'a Plane, p: &'a Patch, c: [f64; 2], a: [f64; 2]) -> Scorer<'a> { + let taps = if is_identity(a) || p.ss <= 1e-12 { None } else { Taps::new(plane, p, c, a) }; + Scorer { plane, p, c, a, taps } + } + + /// The correlation at the reference centre plus `(i, j)` pixels. + fn at(&self, i: i32, j: i32) -> f64 { + self.taps + .as_ref() + .and_then(|t| t.ncc(self.plane, self.p, i as isize, j as isize)) + .unwrap_or_else(|| ncc(self.plane, self.p, [self.c[0] + i as f64, self.c[1] + j as f64], self.a)) + } +} + /// The pre-processed pixels around one point: a pyramid of a crop. Plane coordinates are image /// pixels minus `origin`, divided by `2^level`. struct Crop { origin: [f64; 2], levels: Vec, - grad0: (Plane, Plane), } impl Crop { @@ -280,13 +382,12 @@ impl Crop { if opts.enhance { p = p.enhance(); } - let grad0 = p.gradients(); let mut lv = vec![p]; for _ in 0..levels { let d = lv.last().map(Plane::downsample).unwrap_or_default(); lv.push(d); } - Crop { origin: [x0, y0], levels: lv, grad0 } + Crop { origin: [x0, y0], levels: lv } } fn to_level(&self, p: [f64; 2], k: usize) -> [f64; 2] { let s = (1u32 << k) as f64; @@ -298,6 +399,12 @@ impl Crop { } } +/// Largest distance (in top-level pixels, per axis) between the best coarse match and the +/// motion prediction for which the fine stage searches only ±1 pixel per level. +const SMALL_MOTION: f64 = 1.0; +/// Lowest coarse NCC score of the best match for which the fine stage may search only ±1. +const RELIABLE_SCORE: f64 = 0.8; + /// Per-point tracking state. #[derive(Clone, Debug)] struct PointState { @@ -372,13 +479,15 @@ impl PointState { let (rx, ry) = ((range[0] / s).ceil() as i32, (range[1] / s).ceil() as i32); let plane = &crop.levels[top]; let tpl = &self.templates[top]; + let scorer = Scorer::new(plane, tpl, ctop, self.shape); let shape = self.shape; let mut cands: Vec<(f64, [f64; 2])> = (-ry..=ry) .into_par_iter() .flat_map_iter(|j| { + let scorer = &scorer; (-rx..=rx).map(move |i| { let c = [ctop[0] + i as f64, ctop[1] + j as f64]; - (ncc(plane, tpl, c, shape), c) + (scorer.at(i, j), c) }) }) .collect(); @@ -396,7 +505,10 @@ impl PointState { break; } } - // Fine: refine each seed down the pyramid in a 5 × 5 neighbourhood. + // Fine: refine each seed down the pyramid. A good motion prediction plus a reliable coarse + // match only needs a 3 × 3 neighbourhood per level; otherwise keep 5 × 5. + let near = cands[0].0 >= RELIABLE_SCORE && seeds.first().is_some_and(|c| (c[0] - cp[0]).abs() <= SMALL_MOTION && (c[1] - cp[1]).abs() <= SMALL_MOTION); + let rr: i32 = if near { 1 } else { 2 }; let mut best = (f64::MIN, sc); for seed in seeds { let mut c = seed; @@ -405,14 +517,15 @@ impl PointState { c = [c[0] * 2.0, c[1] * 2.0]; let (pl, tp) = (&crop.levels[k], &self.templates[k]); // Start from the projected match; neighbours must beat it (no drift on ties). - let mut lb = (ncc(pl, tp, c, shape), c); - for j in -2..=2 { - for i in -2..=2 { + let scorer = Scorer::new(pl, tp, c, shape); + let mut lb = (scorer.at(0, 0), c); + for j in -rr..=rr { + for i in -rr..=rr { if i == 0 && j == 0 { continue; } let q = [c[0] + i as f64, c[1] + j as f64]; - let v = ncc(pl, tp, q, shape); + let v = scorer.at(i, j); if v > lb.0 + 1e-9 { lb = (v, q); } @@ -431,15 +544,16 @@ impl PointState { // Sub-pixel (and shape) refinement. let (mut c, mut a) = (c0, shape); if opts.subpixel { + let mut grad = Grad::around(&crop.levels[0], c0, shape, self.templates[0].r); let mut ok = false; if opts.track_shape - && let Some((cr, ar)) = lk_refine(&crop, &self.templates[0], c0, shape, true) + && let Some((cr, ar)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, true) { (c, a) = (cr, ar); ok = true; } if !ok { - if let Some((cr, _)) = lk_refine(&crop, &self.templates[0], c0, shape, false) { + if let Some((cr, _)) = lk_refine(&crop, &mut grad, &self.templates[0], c0, shape, false) { c = cr; } else { c = parabolic(&crop.levels[0], &self.templates[0], c0, shape); @@ -506,12 +620,52 @@ fn parabolic(plane: &Plane, p: &Patch, c: [f64; 2], a: [f64; 2]) -> [f64; 2] { [c[0] + off(f(-1.0, 0.0), f(1.0, 0.0)), c[1] + off(f(0.0, -1.0), f(0.0, 1.0))] } +/// Level-0 gradients of a crop, computed only where `lk_refine` reads them: valid on the pixel +/// rectangle `valid` (x0, y0, x1, y1, inclusive) and zero elsewhere. +struct Grad { + gx: Plane, + gy: Plane, + valid: [isize; 4], +} + +impl Grad { + /// Extra pixels around the first window of taps, for the drift of the refinement. A window that + /// still runs out is replaced by the full gradients. + const MARGIN: isize = 6; + + fn full(plane: &Plane) -> Grad { + let (gx, gy) = plane.gradients(); + Grad { gx, gy, valid: [isize::MIN, isize::MIN, isize::MAX, isize::MAX] } + } + + fn around(plane: &Plane, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> Grad { + let [x0, y0, x1, y1] = Grad::taps(c, a, r); + let m = Grad::MARGIN; + let clip = |v: isize, hi: usize| v.clamp(0, (hi as isize - 1).max(0)) as usize; + let rect = [clip(x0 - m, plane.w), clip(y0 - m, plane.h), clip(x1 + m, plane.w), clip(y1 + m, plane.h)]; + let (gx, gy) = plane.gradients_in(rect); + Grad { gx, gy, valid: rect.map(|v| v as isize) } + } + + /// The pixels read by sampling the template window at `c` with shape `a` (bilinear taps). + fn taps(c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> [isize; 4] { + let ex = (a[0].abs() * r[0] as f64 + a[1].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9; + let ey = (a[1].abs() * r[0] as f64 + a[0].abs() * r[1] as f64) * (1.0 + 1e-9) + 1e-9; + let px = |v: f64| v.clamp(-1e9, 1e9).floor() as isize; + [px(c[0] - ex - 0.5), px(c[1] - ey - 0.5), px(c[0] + ex - 0.5) + 1, px(c[1] + ey - 0.5) + 1] + } + + fn covers(&self, c: [f64; 2], a: [f64; 2], r: [i32; 2]) -> bool { + let t = Grad::taps(c, a, r); + t[0] >= self.valid[0] && t[1] >= self.valid[1] && t[2] <= self.valid[2] && t[3] <= self.valid[3] + } +} + /// Forward-additive Lucas–Kanade (Gauss–Newton) alignment of the level-0 template with gain and /// bias: minimise Σ (I(c + A u) − g T(u) − b)² over the centre `c` and, with `shape`, the /// similarity `A`. Returns `None` when it diverges. -fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> { +fn lk_refine(crop: &Crop, grad: &mut Grad, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> Option<([f64; 2], [f64; 2])> { let plane = &crop.levels[0]; - let (gxp, gyp) = &crop.grad0; let ch = p.ch; let (mut c, mut a) = (c0, a0); let mut g = 1.0f64; @@ -546,6 +700,10 @@ fn lk_refine(crop: &Crop, p: &Patch, c0: [f64; 2], a0: [f64; 2], shape: bool) -> let np = if shape { 6 } else { 4 }; let (mut v, mut vx, mut vy) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]); for _ in 0..30 { + if !grad.covers(c, a, p.r) { + *grad = Grad::full(plane); + } + let (gxp, gyp) = (&grad.gx, &grad.gy); let mut h = [[0.0f64; 6]; 6]; let mut rhs = [0.0f64; 6]; let mut k = 0; diff --git a/crates/track/src/mask.rs b/crates/track/src/mask.rs index e1e133409..898c80f28 100644 --- a/crates/track/src/mask.rs +++ b/crates/track/src/mask.rs @@ -44,6 +44,19 @@ pub fn bounds(p: &[[f64; 2]]) -> [f64; 4] { b } +/// Crop `img` to `region` (layer pixels), with the layer offset of the crop's origin: the part +/// of the frame the tracker reads. `None` when the region leaves too little of the frame. +fn crop_region(img: &Image, off: [f64; 2], region: [f64; 4]) -> Option<(Image, [f64; 2])> { + let x0 = (region[0] + off[0]).floor().max(0.0) as i64; + let y0 = (region[1] + off[1]).floor().max(0.0) as i64; + let x1 = ((region[2] + off[0]).ceil() as i64).min(img.width as i64); + let y1 = ((region[3] + off[1]).ceil() as i64).min(img.height as i64); + if x1 <= x0 + 8 || y1 <= y0 + 8 { + return None; + } + Some((img.crop(x0, y0, (x1 - x0) as u32, (y1 - y0) as u32), [off[0] - x0 as f64, off[1] - y0 as f64])) +} + /// Mask tracker state. #[derive(Clone, Debug)] pub struct MaskTracker { @@ -52,8 +65,8 @@ pub struct MaskTracker { pub outline: Vec<[f64; 2]>, /// The last frame-to-frame motion (a constant-velocity guess for the next frame). velocity: Homography, - prev: Option, - prev_offset: [f64; 2], + /// The previous frame cropped to the region the next step reads, with its layer offset. + prev: Option<(Image, [f64; 2])>, /// Features used on the last step (layer pixels), for display and tests. pub last_features: usize, } @@ -72,44 +85,39 @@ pub struct MaskStep { impl MaskTracker { /// Start on `frame` with the mask `outline` (a closed polyline in layer pixels). pub fn new(model: Model, outline: Vec<[f64; 2]>, frame: &Frame) -> MaskTracker { - MaskTracker { model, outline, velocity: Homography::IDENTITY, prev: Some(frame.img.clone()), prev_offset: frame.offset, last_features: 0 } + let mut t = MaskTracker { model, outline, velocity: Homography::IDENTITY, prev: None, last_features: 0 }; + t.prev = t.region().and_then(|r| crop_region(frame.img, frame.offset, r)); + t + } + + /// Crop region (layer pixels) of the next step: the mask plus room for its motion. + fn region(&self) -> Option<[f64; 4]> { + let b = bounds(&self.outline); + if !(b[0].is_finite() && b[2] > b[0] && b[3] > b[1]) { + return None; + } + let margin = (0.35 * (b[2] - b[0]).max(b[3] - b[1])).max(32.0); + Some([b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin]) } /// Track into `frame` (the next frame in either direction). `None` when the mask's pixels /// can't be followed (no texture, or the mask left the frame). pub fn step(&mut self, frame: &Frame) -> Option { - let prev = self.prev.take()?; - let res = self.step_inner(&prev, self.prev_offset, frame); - self.prev = Some(frame.img.clone()); - self.prev_offset = frame.offset; - let r = res?; - self.outline = self.outline.iter().map(|p| r.motion.apply(*p)).collect(); - self.velocity = r.motion; - Some(r) + let (ca, oa) = self.prev.take()?; + let res = self.step_inner(ca, oa, frame); + if let Some(r) = &res { + self.outline = self.outline.iter().map(|p| r.motion.apply(*p)).collect(); + self.velocity = r.motion; + } + self.prev = self.region().and_then(|r| crop_region(frame.img, frame.offset, r)); + res } - fn step_inner(&mut self, prev: &Image, prev_off: [f64; 2], frame: &Frame) -> Option { + fn step_inner(&mut self, ca: Image, oa: [f64; 2], frame: &Frame) -> Option { let b = bounds(&self.outline); - if !(b[0].is_finite() && b[2] > b[0] && b[3] > b[1]) { - return None; - } let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - // Crop region (layer pixels): the mask plus room for its motion. - let margin = (0.35 * bw.max(bh)).max(32.0); - let (lx0, ly0, lx1, ly1) = (b[0] - margin, b[1] - margin, b[2] + margin, b[3] + margin); - // Both frames share one plane coordinate system: crop in the frames' image pixels. - let crop = |img: &Image, off: [f64; 2]| { - let x0 = (lx0 + off[0]).floor().max(0.0) as i64; - let y0 = (ly0 + off[1]).floor().max(0.0) as i64; - let x1 = ((lx1 + off[0]).ceil() as i64).min(img.width as i64); - let y1 = ((ly1 + off[1]).ceil() as i64).min(img.height as i64); - if x1 <= x0 + 8 || y1 <= y0 + 8 { - return None; - } - Some((img.crop(x0, y0, (x1 - x0) as u32, (y1 - y0) as u32), [off[0] - x0 as f64, off[1] - y0 as f64])) - }; - let (ca, oa) = crop(prev, prev_off)?; - let (cb, ob) = crop(frame.img, frame.offset)?; + // Both frames share one plane coordinate system: the crops are in image pixels. + let (cb, ob) = crop_region(frame.img, frame.offset, self.region()?)?; let factor = analysis_factor(ca.width.max(cb.width), ca.height.max(cb.height), 720); // Pyramids must share coordinates: use a common origin (layer pixels → plane). let levels = 3; @@ -219,4 +227,20 @@ mod tests { assert!(err < 1.5, "frame {f}: max vertex error {err}"); } } + + #[test] + fn follows_the_disk_through_a_frame_offset() { + let off = [40.0, -25.0]; + let pose = |f: usize| Homography([[1.0, 0.0, 140.0 + 5.0 * f as f64], [0.0, 1.0, 110.0 - 3.0 * f as f64], [0.0, 0.0, 1.0]]); + let ring: Vec<[f64; 2]> = (0..24).map(|i| (i as f64 * 15.0).to_radians()).map(|a| [40.0 * a.cos(), 40.0 * a.sin()]).collect(); + let layer = |f: usize| -> Vec<[f64; 2]> { ring.iter().map(|p| pose(f).apply(*p)).map(|q| [q[0] - off[0], q[1] - off[1]]).collect() }; + let f0 = frame(&pose(0)); + let mut mt = MaskTracker::new(Model::Translation, layer(0), &Frame { img: &f0, offset: off }); + for f in 1..6 { + let img = frame(&pose(f)); + mt.step(&Frame { img: &img, offset: off }).expect("tracked"); + let err = mt.outline.iter().zip(&layer(f)).map(|(a, b)| (a[0] - b[0]).hypot(a[1] - b[1])).fold(0.0, f64::max); + assert!(err < 1.0, "frame {f}: max vertex error {err}"); + } + } } diff --git a/crates/track/src/plane.rs b/crates/track/src/plane.rs index bd2d987d6..ff15d08b2 100644 --- a/crates/track/src/plane.rs +++ b/crates/track/src/plane.rs @@ -191,15 +191,32 @@ impl Plane { /// Central-difference gradients `(d/dx, d/dy)`. pub fn gradients(&self) -> (Plane, Plane) { + self.gradients_in([0, 0, self.w.saturating_sub(1), self.h.saturating_sub(1)]) + } + + /// The gradients of the pixels in `[x0, y0, x1, y1]` (inclusive, within the plane); all other + /// pixels of the returned planes stay zero. + pub fn gradients_in(&self, [x0, y0, x1, y1]: [usize; 4]) -> (Plane, Plane) { let mut gx = Plane::new(self.w, self.h, self.ch); let mut gy = Plane::new(self.w, self.h, self.ch); - for y in 0..self.h { - for x in 0..self.w { - for c in 0..self.ch { - let (xi, yi) = (x as isize, y as isize); - let i = self.idx(x, y) + c; - gx.data[i] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c)); - gy.data[i] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c)); + if self.w == 0 || self.h == 0 { + return (gx, gy); + } + let (ch, row) = (self.ch, self.w * self.ch); + for y in y0..=y1.min(self.h - 1) { + for x in x0..=x1.min(self.w - 1) { + let i = self.idx(x, y); + if x > 0 && y > 0 && x + 1 < self.w && y + 1 < self.h { + for c in i..i + ch { + gx.data[c] = 0.5 * (self.data[c + ch] - self.data[c - ch]); + gy.data[c] = 0.5 * (self.data[c + row] - self.data[c - row]); + } + } else { + for c in 0..ch { + let (xi, yi) = (x as isize, y as isize); + gx.data[i + c] = 0.5 * (self.at(xi + 1, yi, c) - self.at(xi - 1, yi, c)); + gy.data[i + c] = 0.5 * (self.at(xi, yi + 1, c) - self.at(xi, yi - 1, c)); + } } } } @@ -240,4 +257,27 @@ mod tests { assert_eq!(Plane::from_image(&img, Channel::Saturation, 0, 0, 2, 2).data[0], 1.0); assert_eq!(Channel::from_name("luminance"), Some(Channel::Luminance)); } + + #[test] + fn gradient_window_and_interior_path_match_the_clamped_definition() { + let mut p = Plane::new(11, 9, 3); + for (i, v) in p.data.iter_mut().enumerate() { + *v = ((i * 7919) % 101) as f32 / 37.0; + } + let (gx, gy) = p.gradients(); + let rect = [2, 1, 8, 6]; + let (wx, wy) = p.gradients_in(rect); + for y in 0..p.h { + for x in 0..p.w { + for c in 0..3 { + let (xi, yi, i) = (x as isize, y as isize, p.idx(x, y) + c); + assert_eq!(gx.data[i].to_bits(), (0.5 * (p.at(xi + 1, yi, c) - p.at(xi - 1, yi, c))).to_bits()); + assert_eq!(gy.data[i].to_bits(), (0.5 * (p.at(xi, yi + 1, c) - p.at(xi, yi - 1, c))).to_bits()); + let inside = (rect[0]..=rect[2]).contains(&x) && (rect[1]..=rect[3]).contains(&y); + assert_eq!(wx.data[i], if inside { gx.data[i] } else { 0.0 }); + assert_eq!(wy.data[i], if inside { gy.data[i] } else { 0.0 }); + } + } + } + } } diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 0948d3fe0..103d695dd 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -339,38 +339,76 @@ pub fn rgb_of(img: &Image) -> Vec<[f32; 3]> { img.data.par_iter().map(|p| if p[3] > 1e-6 { [p[0] / p[3], p[1] / p[3], p[2] / p[3]] } else { [0.0; 3] }).collect() } -/// Pixels covered by `strokes` (of the given kinds) at `scale`. -pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec { - let mut m = vec![false; w * h]; - for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) { - let r = (s.radius * scale).max(0.5); - let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect(); - let segs: Vec<([f64; 2], [f64; 2])> = if pts.len() == 1 { vec![(pts[0], pts[0])] } else { pts.windows(2).map(|w| (w[0], w[1])).collect() }; - for (a, b) in segs { - let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize; - let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w); - let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize; - let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h); - let d = [b[0] - a[0], b[1] - a[1]]; - let l2 = d[0] * d[0] + d[1] * d[1]; - for y in y0..y1 { - for x in x0..x1 { - let p = [x as f64 + 0.5, y as f64 + 0.5]; - let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let q = [a[0] + d[0] * t, a[1] + d[1] * t]; - if (p[0] - q[0]).hypot(p[1] - q[1]) <= r { - m[y * w + x] = true; - } +/// A stroke's radius and segments at `scale`. +fn segments(s: &Stroke, scale: f64) -> (f64, Vec<([f64; 2], [f64; 2])>) { + let pts: Vec<[f64; 2]> = s.points.iter().map(|p| [p[0] * scale, p[1] * scale]).collect(); + let segs = match pts.as_slice() { + [p] => vec![(*p, *p)], + _ => pts.windows(2).map(|w| (w[0], w[1])).collect(), + }; + ((s.radius * scale).max(0.5), segs) +} + +/// Distance from `p` to the segment `a`–`b`. +fn segment_distance(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 { + let d = [b[0] - a[0], b[1] - a[1]]; + let l2 = d[0] * d[0] + d[1] * d[1]; + let t = if l2 > 0.0 { (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / l2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - a[0] - d[0] * t).hypot(p[1] - a[1] - d[1] * t) +} + +/// Call `paint` with every pixel `s` covers at `scale`. +fn sweep(s: &Stroke, scale: f64, w: usize, h: usize, mut paint: impl FnMut(usize)) { + let (r, segs) = segments(s, scale); + for (a, b) in segs { + let x0 = (a[0].min(b[0]) - r).floor().max(0.0) as usize; + let x1 = ((a[0].max(b[0]) + r).ceil().max(0.0) as usize).min(w); + let y0 = (a[1].min(b[1]) - r).floor().max(0.0) as usize; + let y1 = ((a[1].max(b[1]) + r).ceil().max(0.0) as usize).min(h); + for y in y0..y1 { + for x in x0..x1 { + if segment_distance([x as f64 + 0.5, y as f64 + 0.5], a, b) <= r { + paint(y * w + x); } } } } +} + +/// Pixels covered by `strokes` (of the given kinds) at `scale`. +pub fn rasterize(strokes: &[&Stroke], kinds: &[StrokeKind], scale: f64, w: usize, h: usize) -> Vec { + let mut m = vec![false; w * h]; + for s in strokes.iter().filter(|s| kinds.contains(&s.kind)) { + sweep(s, scale, w, h, |i| m[i] = true); + } m } -/// Hard constraints: 1 = foreground, 2 = background, 0 = free. -const HARD_FG: u8 = 1; -const HARD_BG: u8 = 2; +/// What a pair of opposing strokes (add / remove) leaves on each pixel. +const ON: u8 = 1; +const OFF: u8 = 2; + +/// [`ON`] where an `on` stroke covers a pixel, [`OFF`] where an `off` stroke does, 0 elsewhere. +/// Strokes apply in drawing order, so where they overlap the last one painted wins: a stroke +/// corrects the ones before it. +pub fn resolve(strokes: &[&Stroke], on: StrokeKind, off: StrokeKind, scale: f64, w: usize, h: usize) -> Vec { + let mut m = vec![0; w * h]; + for s in strokes { + let v = if s.kind == on { + ON + } else if s.kind == off { + OFF + } else { + continue; + }; + sweep(s, scale, w, h, |i| m[i] = v); + } + m +} + +/// Hard constraints: foreground, background, else free. +const HARD_FG: u8 = ON; +const HARD_BG: u8 = OFF; /// Graph-cut energy weights. const GAMMA: f64 = 50.0; @@ -662,21 +700,9 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S return FrameSeg::empty(w, h); } let rgb = rgb_of(img); - let fgs = rasterize(strokes, &[StrokeKind::Fg], opts.scale, w, h); - let bgs = rasterize(strokes, &[StrokeKind::Bg], opts.scale, w, h); - let hard: Vec = fgs - .iter() - .zip(&bgs) - .map(|(f, b)| { - if *b { - HARD_BG - } else if *f { - HARD_FG - } else { - 0 - } - }) - .collect(); + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let fgs: Vec = hard.iter().map(|v| *v == HARD_FG).collect(); + let bgs: Vec = hard.iter().map(|v| *v == HARD_BG).collect(); // A warped matte is uncertain by about the search radius: blur it. A model's matte is not. let soft_prior: Option> = prior.map(|p| if p.weight > PRIOR { p.soft.clone() } else { matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)) }); @@ -788,8 +814,7 @@ fn core_of(soft: &[f32], w: usize, h: usize, r: f64) -> Vec { fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec, strokes: &[&Stroke], warped_refine: Option<&[u8]>, opts: &SegOpts, refine_radius: f64) -> FrameSeg { let (w, h) = (_img.width as usize, _img.height as usize); let (fg, bg) = fit_models(rgb, &labels); - let add = rasterize(strokes, &[StrokeKind::Refine], opts.scale, w, h); - let erase = rasterize(strokes, &[StrokeKind::RefineErase], opts.scale, w, h); + let painted = resolve(strokes, StrokeKind::Refine, StrokeKind::RefineErase, opts.scale, w, h); let near: Option> = warped_refine.filter(|r| r.iter().any(|v| *v != 0)).map(|_| matting::signed_distance(&labels, w, h)); let rr = (refine_radius * opts.scale + 2.0) as f32; let refine: Vec = (0..w * h) @@ -798,7 +823,7 @@ fn finish(_img: &Image, rgb: &[[f32; 3]], labels: Vec, strokes: &[&Stroke], (Some(r), Some(sd)) => r[i] != 0 && sd[i].abs() <= rr, _ => false, }; - ((carried || add[i]) && !erase[i]) as u8 + ((carried && painted[i] != OFF) || painted[i] == ON) as u8 }) .collect(); FrameSeg { w, h, matte: labels, refine, fg, bg } @@ -877,21 +902,66 @@ fn points_along(s: &Stroke, scale: f64, max: usize) -> Vec<[f32; 2]> { out } -/// A model's prompt from the strokes: points along the foreground and background strokes, and -/// the box of a [`StrokeKind::Box`] stroke (matte pixels). -pub fn stroke_prompt(strokes: &[&Stroke], scale: f64) -> Prompt { +/// A model's prompt from the strokes: points along the foreground and background strokes, but +/// not where a later stroke of the other kind painted over them (`hard`, from [`resolve`]), and +/// the box of a [`StrokeKind::Box`] stroke (matte pixels). A stroke painted over at every point +/// sampled along it, but not everywhere, is prompted from the middle of what is left of it. +pub fn stroke_prompt(strokes: &[&Stroke], hard: &[u8], scale: f64, w: usize, h: usize) -> Prompt { let mut points = vec![]; - for (kind, fg) in [(StrokeKind::Fg, true), (StrokeKind::Bg, false)] { + for (kind, own, other) in [(StrokeKind::Fg, HARD_FG, HARD_BG), (StrokeKind::Bg, HARD_BG, HARD_FG)] { let ss: Vec<&&Stroke> = strokes.iter().filter(|s| s.kind == kind).collect(); let per = (16 / ss.len().max(1)).max(1); for s in ss { - points.extend(points_along(s, scale, per).into_iter().map(|p| (p, fg))); + let along = points_along(s, scale, per); + let mut kept: Vec<[f32; 2]> = along.iter().copied().filter(|p| owner(hard, w, h, *p) != Some(other)).collect(); + if kept.is_empty() && !along.is_empty() { + kept.extend(deepest_owned(s, hard, own, scale, w, h)); + } + points.extend(kept.into_iter().map(|p| (p, kind == StrokeKind::Fg))); } } let bbox = strokes.iter().filter(|s| s.kind == StrokeKind::Box).find_map(|s| box_of(s, scale)); Prompt { points, bbox, ..Default::default() } } +/// The value of `hard` under the point `p` (`None` outside the frame). +fn owner(hard: &[u8], w: usize, h: usize, p: [f32; 2]) -> Option { + if !(p[0] >= 0.0 && p[1] >= 0.0) { + return None; + } + let (x, y) = (p[0] as usize, p[1] as usize); + if x >= w || y >= h { + return None; + } + hard.get(y * w + x).copied() +} + +/// The centre of the pixel deepest inside what `s` still owns (where `hard` is `own` under it), +/// if anything. +fn deepest_owned(s: &Stroke, hard: &[u8], own: u8, scale: f64, w: usize, h: usize) -> Option<[f32; 2]> { + let mut owned = vec![]; + sweep(s, scale, w, h, |i| { + if hard.get(i) == Some(&own) { + owned.push((i % w, i / w)); + } + }); + let (x0, x1) = (owned.iter().map(|p| p.0).min()?, owned.iter().map(|p| p.0).max()?); + let (y0, y1) = (owned.iter().map(|p| p.1).min()?, owned.iter().map(|p| p.1).max()?); + // One pixel of margin all round, so depth is measured to the nearest pixel it doesn't own. + let (cw, ch) = (x1 - x0 + 3, y1 - y0 + 3); + let at = |(x, y): (usize, usize)| (y - y0 + 1) * cw + (x - x0 + 1); + let mut free = vec![true; cw * ch]; + for p in &owned { + if let Some(v) = free.get_mut(at(*p)) { + *v = false; + } + } + let depth = matting::distance(&free, cw, ch); + let d = |p: &(usize, usize)| depth.get(at(*p)).copied().unwrap_or(0.0); + let (x, y) = owned.iter().copied().max_by(|a, b| d(a).total_cmp(&d(b)))?; + Some([x as f32 + 0.5, y as f32 + 0.5]) +} + /// The box `[x0, y0, x1, y1]` (matte pixels) of a box stroke: its first two points as opposite /// corners; `None` when they are missing, not finite or the box is empty. fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> { @@ -901,8 +971,18 @@ fn box_of(s: &Stroke, scale: f64) -> Option<[f32; 4]> { } /// Add a prior matte to a prompt: its box (a little larger; unless the prompt has one), up to -/// four points spread through its core, and the matte itself. -fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bool { +/// four points spread through its core, and the matte itself, all with this frame's strokes +/// (`hard`, from [`resolve`]) painted over it. +fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], hard: &[u8], w: usize, h: usize) -> bool { + let soft: Vec = soft + .iter() + .enumerate() + .map(|(i, v)| match hard.get(i) { + Some(&HARD_FG) => 1.0, + Some(&HARD_BG) => 0.0, + _ => *v, + }) + .collect(); let bin: Vec = soft.iter().map(|v| (*v >= 0.5) as u8).collect(); let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); for (i, v) in bin.iter().enumerate() { @@ -932,7 +1012,7 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo } } prompt.points.extend(picks.into_iter().map(|i| ([(i % w) as f32 + 0.5, (i / w) as f32 + 0.5], true))); - prompt.mask = Some(soft.to_vec()); + prompt.mask = Some(soft); true } @@ -941,9 +1021,10 @@ fn add_prior_prompt(prompt: &mut Prompt, soft: &[f32], w: usize, h: usize) -> bo pub fn segment_with(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64, model: Option<&dyn MaskModel>) -> FrameSeg { let (w, h) = (img.width as usize, img.height as usize); let Some(m) = model.filter(|_| w > 0 && h > 0) else { return segment(img, strokes, prior, opts, refine_radius) }; - let mut prompt = stroke_prompt(strokes, opts.scale); + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h); if let Some(p) = prior { - add_prior_prompt(&mut prompt, &p.soft, w, h); + add_prior_prompt(&mut prompt, &p.soft, &hard, w, h); } if !prompt.points.iter().any(|(_, fg)| *fg) { return segment(img, strokes, prior, opts, refine_radius); @@ -973,8 +1054,9 @@ pub fn propagate_with( let (w, h) = (next_img.width as usize, next_img.height as usize); let Some(m) = model.filter(|_| w > 0 && h > 0) else { return propagate(prev_img, prev, next_img, strokes, opts, refine_radius) }; let prior = warp_prior(prev_img, prev, next_img, opts); - let mut prompt = stroke_prompt(strokes, opts.scale); - if !add_prior_prompt(&mut prompt, &prior.soft, w, h) { + let hard = resolve(strokes, StrokeKind::Fg, StrokeKind::Bg, opts.scale, w, h); + let mut prompt = stroke_prompt(strokes, &hard, opts.scale, w, h); + if !add_prior_prompt(&mut prompt, &prior.soft, &hard, w, h) { return propagate(prev_img, prev, next_img, strokes, opts, refine_radius); } match m.segment(&rgb_of(next_img), w, h, &prompt) { diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index 92c7bec04..8a5cab98c 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -182,6 +182,28 @@ fn a_correction_stroke_fixes_a_bad_frame() { assert!(iou(&next.matte, >2) > 0.93); } +/// A stroke corrects the ones painted before it: foreground over an earlier background stroke +/// restores the area, and background over that removes it again. +#[test] +fn later_strokes_override_earlier_ones() { + let c = center(0); + let (img, gt) = frame_at(c); + let spot = [c[0], c[1] + 20.0]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let red = line(StrokeKind::Bg, 0, [spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]], 6.0); + let green = Stroke { kind: StrokeKind::Fg, ..red.clone() }; + let seg = |st: &[Stroke]| segment(&img, &st.iter().collect::>(), None, &SegOpts::default(), 0.0); + let mut st = base_strokes(0); + st.push(red.clone()); + assert_eq!(seg(&st).matte[i], 0, "background over the disk removes it"); + st.push(green); + let restored = seg(&st); + assert_eq!(restored.matte[i], 1, "foreground over the background stroke restores it"); + assert!(iou(&restored.matte, >) > 0.95, "IoU {}", iou(&restored.matte, >)); + st.push(red); + assert_eq!(seg(&st).matte[i], 0, "background again removes it again"); +} + /// A soft-edged disk (alpha ramp over `width` pixels) over a background. fn soft_frame(c: [f64; 2], width: f64, fg: [f32; 3], bg: [f32; 3]) -> (Image, Vec) { let mut img = Image::new(W, H); @@ -265,6 +287,31 @@ fn refine_edge_recovers_a_soft_edge_and_decontaminates() { assert!(a2.iter().zip(&seg.matte).all(|(x, y)| (*x - *y as f32).abs() < 1e-6)); } +/// Refine Edge strokes correct each other the same way: refine over an earlier erase restores +/// the band there. +#[test] +fn later_refine_strokes_override_earlier_erases() { + let c = [100.0, 75.0]; + let (img, _) = soft_frame(c, 10.0, [0.9, 0.25, 0.1], [0.1, 0.3, 0.9]); + let spot = [c[0] + R, c[1]]; + let i = spot[1] as usize * W as usize + spot[0] as usize; + let erase = Stroke { kind: StrokeKind::RefineErase, frame: 0, radius: 6.0, points: vec![spot] }; + let refine = Stroke { kind: StrokeKind::Refine, ..erase.clone() }; + let band = |st: &[Stroke]| segment(&img, &st.iter().collect::>(), None, &SegOpts::default(), 9.0).refine[i]; + let mut st = vec![ + line(StrokeKind::Fg, 0, [c[0] - 15.0, c[1]], [c[0] + 15.0, c[1]], 5.0), + line(StrokeKind::Bg, 0, [c[0] - 60.0, c[1] - 55.0], [c[0] + 60.0, c[1] - 55.0], 5.0), + ring(0, c, R, 9.0), + ]; + assert_eq!(band(&st), 1); + st.push(erase.clone()); + assert_eq!(band(&st), 0, "erase removes the band"); + st.push(refine); + assert_eq!(band(&st), 1, "refine over the erase restores it"); + st.push(erase); + assert_eq!(band(&st), 0, "erase again removes it again"); +} + #[test] fn reduce_chatter_and_motion_blur_use_the_neighbours() { let (w, h) = (40usize, 20usize); @@ -373,9 +420,8 @@ impl effectcraft_segment::MaskModel for FakeModel { } /// A grey bar on a two-tone background whose halves match the bar's halves: colour alone can't -/// find its edge, a model can. -#[test] -fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { +/// find its edge, a model can. The frame, its truth and the bar's rectangle. +fn bar_frame() -> (Image, Vec, [usize; 4]) { let (w, h) = (160usize, 100usize); let rect = [40, 30, 120, 70]; let mut img = Image::new(w as u32, h as u32); @@ -390,6 +436,12 @@ fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { gt[y * w + x] = inside as u8; } } + (img, gt, rect) +} + +#[test] +fn a_model_prior_shapes_the_cut_and_bad_models_are_ignored() { + let (img, gt, rect) = bar_frame(); let st = [line(StrokeKind::Fg, 0, [50.0, 50.0], [110.0, 50.0], 4.0)]; let refs: Vec<&Stroke> = st.iter().collect(); let opts = SegOpts::default(); @@ -450,18 +502,80 @@ fn mobilesam_segments_and_propagates_the_disk() { fn box_stroke_is_the_model_box_prompt() { let fg = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 2.0, points: vec![[50.0, 40.0]] }; let bx = Stroke { kind: StrokeKind::Box, frame: 0, radius: 0.0, points: vec![[80.0, 60.0], [20.0, 10.0]] }; - let p = stroke_prompt(&[&fg, &bx], 0.5); + let p = stroke_prompt(&[&fg, &bx], &[], 0.5, 0, 0); assert_eq!(p.bbox, Some([10.0, 5.0, 40.0, 30.0])); assert_eq!(p.points, vec![([25.0, 20.0], true)]); // Degenerate or non-finite boxes are no prompt. for pts in [vec![[20.0, 10.0]], vec![[20.0, 10.0], [20.0, 50.0]], vec![[f64::NAN, 10.0], [80.0, 60.0]]] { let b = Stroke { points: pts, ..bx.clone() }; - assert_eq!(stroke_prompt(&[&fg, &b], 1.0).bbox, None); + assert_eq!(stroke_prompt(&[&fg, &b], &[], 1.0, 0, 0).bbox, None); } assert!(!StrokeKind::Box.is_segmentation()); assert!(rasterize(&[&bx], &[StrokeKind::Fg, StrokeKind::Bg], 1.0, 100, 80).iter().all(|v| !v)); - let mut q = stroke_prompt(&[&fg, &bx], 1.0); + let mut q = stroke_prompt(&[&fg, &bx], &[], 1.0, 0, 0); let soft: Vec = (0..100 * 80).map(|i| ((i % 100) < 10) as u8 as f32).collect(); - assert!(add_prior_prompt(&mut q, &soft, 100, 80)); + assert!(add_prior_prompt(&mut q, &soft, &[], 100, 80)); assert_eq!(q.bbox, Some([20.0, 10.0, 80.0, 60.0])); } + +/// A model is never prompted against the latest correction: stroke points that a later stroke +/// of the other kind painted over are dropped, and the propagated matte's points and mask leave +/// out what this frame's background strokes removed. +#[test] +fn model_prompts_follow_the_latest_stroke() { + let green = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 4.0, points: vec![[50.0, 40.0]] }; + let red = Stroke { kind: StrokeKind::Bg, ..green.clone() }; + let (w, h) = (100, 80); + let prompt = |st: &[&Stroke]| stroke_prompt(st, &resolve(st, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h), 1.0, w, h).points; + assert_eq!(prompt(&[&green, &red]), vec![([50.0, 40.0], false)]); + assert_eq!(prompt(&[&green, &red, &green]), vec![([50.0, 40.0], true); 2]); + let soft: Vec = (0..w * h).map(|i| ((i % w) < 50) as u8 as f32).collect(); + let erase = Stroke { kind: StrokeKind::Bg, frame: 0, radius: 15.0, points: vec![[25.0, 40.0]] }; + let hard = resolve(&[&erase], StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + let mut q = Prompt::default(); + assert!(add_prior_prompt(&mut q, &soft, &hard, w, h)); + assert!(!q.points.is_empty()); + assert!(q.points.iter().all(|(p, fg)| *fg && owner(&hard, w, h, *p) != Some(HARD_BG)), "{:?}", q.points); + let mask = q.mask.unwrap(); + assert!(hard.iter().zip(&mask).all(|(hd, m)| *hd != HARD_BG || *m == 0.0)); +} + +/// A stand-in model that counts its calls; its foreground is a fixed rectangle. +struct CountingModel([usize; 4], std::sync::atomic::AtomicUsize); + +impl effectcraft_segment::MaskModel for CountingModel { + fn info(&self) -> &'static effectcraft_segment::ModelInfo { + &effectcraft_segment::MOBILE_SAM + } + fn segment(&self, _: &[[f32; 3]], w: usize, h: usize, _: &Prompt) -> Result, String> { + self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let [x0, y0, x1, y1] = self.0; + Ok((0..w * h).map(|i| ((i % w) >= x0 && (i % w) < x1 && (i / w) >= y0 && (i / w) < y1) as u8 as f32 * 0.98 + 0.01).collect()) + } +} + +/// A small background correction in the middle of a large foreground dot removes the only point +/// sampled along the dot, but most of the dot is still foreground: the model is still prompted +/// with it, from a point inside what is left. A dot painted over completely prompts nothing. +#[test] +fn a_correction_keeps_the_model_prompted_with_what_is_left() { + let (img, gt, rect) = bar_frame(); + let (w, h) = (img.width as usize, img.height as usize); + let dot = Stroke { kind: StrokeKind::Fg, frame: 0, radius: 15.0, points: vec![[80.0, 50.0]] }; + let tap = Stroke { kind: StrokeKind::Bg, radius: 2.0, ..dot.clone() }; + let refs = [&dot, &tap]; + let model = CountingModel(rect, Default::default()); + let seg = segment_with(&img, &refs, None, &SegOpts::default(), 0.0, Some(&model)); + assert_eq!(model.1.load(std::sync::atomic::Ordering::Relaxed), 1, "the model was prompted"); + assert!(iou(&seg.matte, >) > 0.95, "IoU {}", iou(&seg.matte, >)); + let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + let fg: Vec<[f32; 2]> = stroke_prompt(&refs, &hard, 1.0, w, h).points.into_iter().filter(|p| p.1).map(|p| p.0).collect(); + let [q] = fg.as_slice() else { panic!("one foreground point, got {fg:?}") }; + let r = (q[0] - 80.0).hypot(q[1] - 50.0); + assert!(r > 5.0 && r < 13.0, "{q:?} sits inside what is left of the dot"); + assert_eq!(hard[q[1] as usize * w + q[0] as usize], HARD_FG); + let cover = Stroke { radius: 16.0, ..tap }; + let refs = [&dot, &cover]; + let hard = resolve(&refs, StrokeKind::Fg, StrokeKind::Bg, 1.0, w, h); + assert!(stroke_prompt(&refs, &hard, 1.0, w, h).points.iter().all(|p| !p.1)); +} diff --git a/crates/track/src/stabilize.rs b/crates/track/src/stabilize.rs index 9769fcbd0..90cab6aab 100644 --- a/crates/track/src/stabilize.rs +++ b/crates/track/src/stabilize.rs @@ -156,10 +156,27 @@ impl Analyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { + self.push_impl(frame, !self.opts.detailed); + } + + fn push_impl(&mut self, frame: &Frame, share_pyramid: bool) { + let share_pyramid = share_pyramid && !self.opts.detailed; let (max_side, max_feat) = if self.opts.detailed { (960, 700) } else { (480, 350) }; let factor = analysis_factor(frame.img.width, frame.img.height, max_side); - let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 3); - self.trajectories.push(frame); + let mut pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, if share_pyramid { 4 } else { 3 }); + if share_pyramid { + self.trajectories.push_pyramid(self.prev.as_ref(), &pyr); + } else { + // Detailed analysis uses different downsampling factors for motion and trajectories. + self.trajectories.push(frame); + } + // Motion tracking historically used only three extra levels. Temporarily remove + // the trajectory tracker's fourth level without cloning any planes or gradients. + let extra = if share_pyramid && pyr.levels.len() > 4 { pyr.levels.pop().zip(pyr.grads.pop()) } else { None }; + if share_pyramid && let Some(prev) = &mut self.prev { + prev.levels.truncate(4); + prev.grads.truncate(4); + } let mut fm = FrameMotion::default(); if let Some(prev) = &self.prev && prev.width() == pyr.width() @@ -195,6 +212,10 @@ impl Analyzer { fm.points = round_pts(&inl, 48); } } + if let Some((level, grad)) = extra { + pyr.levels.push(level); + pyr.grads.push(grad); + } self.frames.push(fm); self.prev = Some(pyr); } @@ -833,6 +854,39 @@ mod tests { tot / cnt.max(1) as f64 } + #[test] + fn shared_pyramid_preserves_motion_and_trajectory_bits() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (size, detailed) in [([321.0, 181.0], false), ([997.0, 565.0], false), ([97.0, 63.0], false), ([1281.0, 721.0], true)] { + let mut scene = Scene::new(12); + scene.size = size; + let mut shared = Analyzer::new(size, AnalyzeOpts { detailed }); + let mut separate = Analyzer::new(size, AnalyzeOpts { detailed }); + for k in 0..12 { + // Also cover partial downsample blocks, an offset/size change and the + // resulting reset of active features halfway through the clip. + if k == 6 { + scene.size = [size[0] + 7.0, size[1] + 5.0]; + } + let img = scene.frame(k); + let frame = Frame { img: &img, offset: if k < 6 { [2.25, -3.5] } else { [-1.75, 4.5] } }; + shared.push(&frame); + separate.push_impl(&frame, false); + } + let a = shared.finish(0.123456789012345, 1.0 / 24.0); + let b = separate.finish(0.123456789012345, 1.0 / 24.0); + assert!(!a.tracks.is_empty(), "exercise live trajectories: {size:?}, detailed={detailed}"); + assert!(a.frames.iter().any(|f| f.features > 0)); + assert_eq!(a.to_json(), b.to_json(), "{size:?}, detailed={detailed}"); + for (a, b) in a.frames.iter().zip(&b.frames) { + for (a, b) in a.t.iter().chain(&a.s).chain(&a.h).zip(b.t.iter().chain(&b.s).chain(&b.h)) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } + } + }); + } + #[test] fn subspace_warp_beats_perspective_on_parallax() { let n = 48; diff --git a/crates/track/src/tests.rs b/crates/track/src/tests.rs index 886478d1e..c019996f5 100644 --- a/crates/track/src/tests.rs +++ b/crates/track/src/tests.rs @@ -93,6 +93,55 @@ fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt() } +fn bits(v: &Option<([f64; 2], [f64; 2])>) -> Option<[u64; 4]> { + v.map(|(c, a)| [c[0].to_bits(), c[1].to_bits(), a[0].to_bits(), a[1].to_bits()]) +} + +#[test] +fn scorer_matches_ncc_for_integer_offsets() { + let pose = Pose { c: [60.0, 50.0], rot: 0.0, scale: 1.0 }; + let img = frame(120, 100, pose, 20.0, 0.02, 1, None); + let crop = Crop::new(&Frame::new(&img), &TrackOptions::default(), [60.0, 50.0], [40.0, 30.0], 1); + for (lvl, c, a) in [(0, [30.3, 25.9], [0.97, 0.21]), (0, [12.0, 9.5], [1.2, -0.3]), (1, [20.4, 14.2], [0.8, 0.0]), (0, [30.3, 25.9], [1.0, 0.0])] { + let plane = &crop.levels[lvl]; + let r = [7, 5]; + let patch = Patch::sample(plane, c, a, r); + let sc = Scorer::new(plane, &patch, [c[0] + 0.3, c[1] - 0.45], a); + for j in -9..=9 { + for i in -9..=9 { + let q = [c[0] + 0.3 + i as f64, c[1] - 0.45 + j as f64]; + assert_eq!(sc.at(i, j).to_bits(), ncc(plane, &patch, q, a).to_bits(), "level {lvl} {a:?} ({i}, {j})"); + } + } + } +} + +#[test] +fn lk_refine_with_a_gradient_window_equals_the_full_gradients() { + let pose = Pose { c: [100.0, 90.0], rot: 5.0, scale: 1.05 }; + let (img0, img1) = (frame(200, 180, pose, 30.0, 0.02, 1, None), frame(200, 180, Pose { c: [103.2, 88.1], rot: 6.5, scale: 1.08 }, 30.0, 0.02, 2, None)); + let (opts, r) = (TrackOptions::default(), [24, 24]); + let (c0, a0) = ([100.0, 90.0], [1.0, 0.0]); + let tpl = Crop::new(&Frame::new(&img0), &opts, c0, [60.0, 60.0], 0); + let crop = Crop::new(&Frame::new(&img1), &opts, c0, [60.0, 60.0], 0); + let plane = &crop.levels[0]; + let patch = Patch::sample(&tpl.levels[0], tpl.to_level(c0, 0), a0, r); + let mid = crop.to_level(c0, 0); + // Starts within the window, and far enough off that the iterations leave it (even near the edge). + for (d, edge) in [([0.0, 0.0], false), ([1.3, -0.8], false), ([4.0, 5.0], false), ([9.0, -9.0], false), ([0.0, 0.0], true)] { + let start = if edge { [4.0, 4.0] } else { [mid[0] + d[0], mid[1] + d[1]] }; + for shape in [true, false] { + let (mut full, mut win) = (Grad::full(plane), Grad::around(plane, start, a0, r)); + let x = lk_refine(&crop, &mut full, &patch, start, a0, shape); + let y = lk_refine(&crop, &mut win, &patch, start, a0, shape); + assert_eq!(bits(&x), bits(&y), "{d:?} edge {edge} shape {shape}"); + // A window that misses the taps altogether is replaced by the full gradients. + let mut off = Grad::around(plane, [start[0] + 15.0, start[1] + 15.0], a0, r); + assert_eq!(bits(&x), bits(&lk_refine(&crop, &mut off, &patch, start, a0, shape)), "{d:?} edge {edge} shape {shape}: missed window"); + } + } +} + #[test] fn recovers_translation_path_subpixel() { let path = |f: usize| { @@ -114,6 +163,27 @@ fn recovers_translation_path_subpixel() { assert!(worst < 0.25, "worst error {worst} px"); } +#[test] +fn recovers_after_sudden_jumps_the_prediction_misses() { + // Alternating large steps defeat the velocity prediction, so the fine stage must keep its + // wider neighbourhood; smooth frames in between use the narrow one. + let path = |f: usize| { + let t = f as f64; + let jump = [0.0, 7.0, -5.0, 9.0, -8.0, 6.0, -7.0, 8.0]; + let j = jump[f % jump.len()]; + Pose { c: [120.0 + 1.5 * t + j, 100.0 + 1.1 * t - 0.6 * j], rot: 0.0, scale: 1.0 } + }; + let f0 = frame(360, 260, path(0), 40.0, 0.02, 0, None); + let mut tr = Tracker::new(TrackOptions::default(), &[spec(path(0).c, 31.0, 91.0)], &Frame::new(&f0)); + for f in 1..24 { + let img = frame(360, 260, path(f), 40.0, 0.02, f as u32, None); + let r = tr.step(&Frame::new(&img))[0]; + let e = dist(r.center, path(f).c); + assert!(e < 0.3, "frame {f}: error {e} px"); + assert_eq!(r.status, Status::Tracked); + } +} + #[test] fn layer_offset_and_channels() { let pose = |f: usize| Pose { c: [120.0 + 3.3 * f as f64, 100.0 - 1.7 * f as f64], rot: 0.0, scale: 1.0 }; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..79c077b2a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -15,6 +15,7 @@ mod screenshot; mod viewer_frames; mod window; +pub use gpu::{GPU_PREPARING, GpuBuild}; #[cfg(test)] pub(crate) use screenshot::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; @@ -74,8 +75,12 @@ impl EffectcraftApp { wgpu: None, gpu: None, gpu_checked: false, + #[cfg(not(target_arch = "wasm32"))] + gpu_build: None, gpu_failures: None, gpu_failure_applied: None, + footage_watcher: None, + footage_watch_key: None, device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index f519bcdde..27113d4cd 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -127,10 +127,13 @@ impl EffectcraftApp { self.now = self.clock.now(); // The compositor exists before the first paint and while hidden (device-loss handling // must run there too). - self.init_gpu(frame); + self.init_gpu(ctx, frame); self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); + // On macOS the app quits through `-[NSApplication terminate:]` instead of closing the + // window while AppKit's run loop is running (#341). + craft_quit::on_close_request(ctx); return; } // Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a @@ -141,6 +144,9 @@ impl EffectcraftApp { // commands handled here use the right catalog already. let language = crate::i18n::language(self); crate::i18n::set_current(language); + // #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the + // status and the menu update even when the window is not painting. + crate::panels::text_panels::poll_font_rescan(self, ctx); if self.styled_language != language { self.want_paint = true; } @@ -384,6 +390,11 @@ impl EffectcraftApp { let full = ui.max_rect(); if self.dialog.is_some() { ui.disable(); + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; @@ -461,6 +472,13 @@ impl EffectcraftApp { self.ui.status.clear(); self.toast = None; } + // "Preparing the GPU compositor…" stays until the compositor is attached (tens of + // seconds on a cold shader cache), not 5 s: `finish_gpu_build` clears it, and the + // worker's repaint brings that frame, so nothing needs to tick meanwhile. A click + // still dismisses it. + if self.ui.status == crate::GPU_PREPARING && self.gpu_initializing() { + return; + } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); if !self.ui.status.is_empty() { let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now)); @@ -524,33 +542,40 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } - self.tick_footage_reload(ctx, now); + self.tick_footage_reload(ctx); } - /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the - /// window comes back to the front (after saving in another app), stamp the footage files on a - /// worker thread and reload the ones that changed. - fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage + /// files (every two seconds while the window is in front, and as soon as it comes back to + /// the front after saving in another app) and wakes this frame only when a stamp changed; + /// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with + /// unchanged footage stays asleep. + fn tick_footage_reload(&mut self, ctx: &egui::Context) { use effectcraft_engine::footage_reload; - if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take) + && let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps) + { self.ui.status = e.to_string(); } - let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused")); - let focused = self.focused; - let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); - ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { + let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty(); + if off { + if let Some(w) = &mut self.footage_watcher { + w.set_watched(Default::default()); + } + self.footage_watch_key = None; return; } - let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); - if now - last >= 2.0 || (focused && !was_focused) { - ctx.data_mut(|d| d.insert_temp(last_key, now)); - footage_reload::start_scan(&mut self.session); + if self.footage_watcher.is_none() { + let wake = ctx.clone(); + self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint()); } - if self.session.footage_scan.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs(2)); + let Some(watcher) = &mut self.footage_watcher else { return }; + watcher.set_focused(self.focused); + // Which files are watched follows the project and the preference, not the frame. + let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone()); + if self.footage_watch_key.as_ref() != Some(&key) { + self.footage_watch_key = Some(key); + watcher.set_watched(footage_reload::watched(&self.session)); } } } diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 9febfa413..225a8727c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,5 +1,12 @@ use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; +/// The status line while the GPU compositor's worker thread is still building its pipelines +/// (see [`EffectcraftApp::init_gpu`]). +pub const GPU_PREPARING: &str = "Preparing the GPU compositor… (compositing on the CPU until it is ready)"; +/// What the compositor's worker thread hands back: the compositor, why the device can't run it, +/// or the payload of a panic in the graphics backend. +pub type GpuBuild = std::thread::Result>; + impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { RenderSource { @@ -124,7 +131,19 @@ impl EffectcraftApp { /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. - pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { + /// + /// On the desktop the compositor is built on a worker thread. Its compute pipelines (about + /// 235 of them) take tens of seconds to compile on a cold shader cache (first launch, after + /// an OS update, after an update that changed a shader), and eframe shows the window only + /// once the first frame has painted, so building them here kept the window from appearing + /// at all. Until the worker is done the viewer composites on the CPU, as it does without a + /// GPU, and the status bar says so; the browser has no threads and builds it in place. + pub(crate) fn init_gpu(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + #[cfg(not(target_arch = "wasm32"))] + if self.gpu_build.is_some() { + self.finish_gpu_build(frame.wgpu_render_state()); + return; + } if self.gpu_checked { return; } @@ -142,10 +161,80 @@ impl EffectcraftApp { info.device_type, if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } )); + #[cfg(not(target_arch = "wasm32"))] + if self.spawn_gpu_build(ctx, rs) { + return; + } + // In place (the browser, or no thread to be had): nothing to wake later. + let _ = ctx; // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. - let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); - match gpu { + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); + self.attach_gpu(built, Some(rs)); + } + + /// Start building the compositor on a `gpu-compositor` worker thread, which hands its result + /// back to [`Self::finish_gpu_build`] and wakes the UI. False when no thread could be spawned + /// (the caller then builds it in place). + #[cfg(not(target_arch = "wasm32"))] + fn spawn_gpu_build(&mut self, ctx: &egui::Context, rs: &eframe::egui_wgpu::RenderState) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + let (adapter, device, queue) = (rs.adapter.clone(), rs.device.clone(), rs.queue.clone()); + let gate = self.hooks.before_gpu_build.take(); + let wake = ctx.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor".into()).spawn(move || { + // Backend validation can panic on shader compilation even when device creation + // succeeded: the panic stays on this thread and arrives as a result. + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(gate) = gate { + gate(); + } + effectcraft_gpu::Gpu::new(&adapter, device, queue) + })); + // No receiver: the window closed, or the device failed, while this was running. + let _ = tx.send(built); + wake.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread"); + return false; + } + self.gpu_build = Some(rx); + self.session.accel_note = Some(GPU_PREPARING.into()); + if self.ui.status.is_empty() { + self.ui.status = GPU_PREPARING.into(); + } + true + } + + /// Take the worker's result when it has arrived ([`Self::init_gpu`]). `rs`: the window's + /// device, which a compositor that built is attached to. + #[cfg(not(target_arch = "wasm32"))] + fn finish_gpu_build(&mut self, rs: Option<&eframe::egui_wgpu::RenderState>) { + let Some(rx) = &self.gpu_build else { return }; + let built = match rx.try_recv() { + Ok(built) => built, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Err(Box::new("the compositor's worker thread ended without a result") as Box) + } + }; + self.gpu_build = None; + if self.ui.status == GPU_PREPARING { + self.ui.status.clear(); + } + // The device failed while the compositor was being built: it stays detached, and + // `apply_gpu_failure` has already said why the viewer is on the CPU. + if self.gpu_failure_applied.is_some() || self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) { + return; + } + self.attach_gpu(built, rs); + } + + /// Attach a built compositor to the viewer, or record why there is none. Keeps the document + /// and the egui renderer either way; without a compositor the viewer composites on the CPU. + fn attach_gpu(&mut self, built: GpuBuild, rs: Option<&eframe::egui_wgpu::RenderState>) { + match built { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); @@ -153,7 +242,12 @@ impl EffectcraftApp { return; } } + let Some(rs) = rs else { + self.session.accel_note = Some("GPU compositor unavailable: the window has no wgpu device".into()); + return; + }; log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); + self.session.accel_note = None; self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); @@ -170,6 +264,19 @@ impl EffectcraftApp { } } + /// Whether the GPU compositor is still being built on its worker thread (the viewer is on + /// the CPU meanwhile). Always false in the browser, which builds it with the first frame. + pub fn gpu_initializing(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.gpu_build.is_some() + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// The viewer shows a frame composited on the GPU (drawn from its wgpu texture). pub fn viewer_on_gpu(&self) -> bool { matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 912a196f8..d40bc69ff 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -40,11 +40,16 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + starting: false, + starting_since: now, + first_cached: None, shown_at: Default::default(), }; if !self.playback.caching { if plan.video { - self.playback.audio_held = self.audio_plays(); + let audible = self.audio_plays(); + self.playback.audio_held = audible; + self.playback.starting = audible; } else { self.start_audio(fr.tick_of(plan.first)); } @@ -104,6 +109,7 @@ impl EffectcraftApp { let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; self.playback.caching = false; + self.playback.starting = false; if !was { return; } diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 1fdf0aad1..d351ca411 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the +/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever: +/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does. +const STARTUP_WAIT: f64 = 2.0; + +/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame +/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from +/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size +/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled +/// one does: its sound starts once the frames are cached. +const STARTUP_LIMIT: f64 = 10.0; + +/// How many frames from the first a preview with sound waits for before it starts (#947): the +/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets +/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at +/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer +/// always requests. Waiting for a frame that is never requested, or that can't stay cached with +/// the others, would hold the first frame for ever. +fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 { + let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64; + ahead.min(window).min(fit).max(1) +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -53,6 +76,9 @@ impl EffectcraftApp { return; } let fit = self.frames_that_fit(&c, scale); + if self.hold_first_frame(&pass, fit) { + return; + } self.start_held_audio(&pass, fit); if self.advance_audio_clock(&pass, step) { return; @@ -179,8 +205,54 @@ impl EffectcraftApp { } } - /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the - /// prefetch window when the silent preview keeps real time, else the rest of the preview. + /// Starting a preview with sound: hold the first frame until the frames ahead are cached, then + /// start the sound from that same frame (see `Playback::starting`). Without an output device + /// the picture plays on its own clock from it. How many frames ahead: only ones + /// `prefetch_playback` requests and that fit in the RAM preview at the size they really render + /// (an Extended Viewer frame is larger than the comp), so the wait always ends. Returns true + /// while it holds the first frame. + fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool { + let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; + if !self.playback.starting { + return false; + } + let Some(p) = plan else { return false }; + self.playback.start_wall = now; + self.playback.start_frame = cur; + // `prefetch_playback` requests every uncached frame of offsets 0..window from here. + let window = (ahead * 3).min(fit - 1); + let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| { + let [w, h] = img.size(); + startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) + }); + if need.is_some() && self.playback.first_cached.is_none() { + self.playback.first_cached = Some(now); + } + // Don't wait for frames the RAM preview can't hold (not even one frame fits), for the + // frames ahead longer than STARTUP_WAIT once the first is in, nor longer than + // STARTUP_LIMIT in all (a frame that never gets cached). + let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale) + || self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT) + || now - self.playback.starting_since >= STARTUP_LIMIT; + if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { + self.playback.waiting = true; + ctx.request_repaint(); + return true; + } + self.playback.starting = false; + self.playback.waiting = false; + // Not on a frame that isn't cached: the stall below would stop the sound at once. The + // preview goes on as a stalled one does, and the sound starts once frames are cached. + if need.is_some() { + self.playback.audio_held = false; + self.start_audio(fr.tick_of(cur)); + } + false + } + + /// Held sound starts once the frames ahead are cached again after a stall (see + /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else + /// the rest of the preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { @@ -334,7 +406,8 @@ impl EffectcraftApp { } let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb); let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n); - let slots = (self.frames_parallelism() / 2).max(1); + // One thread each (`Frames::request_background`), so every frame thread takes one. + let slots = self.frames_parallelism().max(1); let mut queued = self.frames.inflight(); let scale = series.scale as f64 / 1000.0; let mut pending = false; @@ -347,7 +420,7 @@ impl EffectcraftApp { if queued >= slots { break; } - self.request_frame(ItemId(series.comp), f, scale); + self.request_frame_background(ItemId(series.comp), f, scale); queued += 1; } if pending { @@ -365,9 +438,13 @@ impl EffectcraftApp { } fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 { + ((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1) + } + + /// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger). + fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 { let side = |n: u32| (n as f64 * scale).ceil().max(1.0); - let frame = side(comp.width) * side(comp.height) * 4.0; - ((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1) + side(comp.width) * side(comp.height) * 4.0 } fn frames_parallelism(&self) -> usize { @@ -379,3 +456,28 @@ impl EffectcraftApp { self.frames.parallelism() } } + +#[cfg(test)] +mod startup_tests { + use super::startup_frames; + + /// The frames a preview with sound waits for are always requested and fit together (#947). + #[test] + fn startup_waits_only_for_frames_it_requests_and_can_keep() { + // A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch + // window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for. + let frame = 64 * 36 * 4; + let fit = 4; + let window = (32i64 * 3).min(fit - 1); + assert_eq!(startup_frames(32, window, 46_080, frame), 3); + // A roomy budget: the prefetch's `ahead` frames. + assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8); + // Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized + // frames but only 2 real ones (90 %): wait for 2, not 4. + assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2); + // Nothing fits, or no window: still the current frame, never 0. + assert_eq!(startup_frames(8, 0, 0, frame), 1); + // A zero frame size counts as 1 byte: no division by zero. + assert_eq!(startup_frames(8, 24, 10, 0), 8); + } +} diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index 3c8632cea..c878fafa4 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -126,12 +126,18 @@ impl EffectcraftApp { /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, false); + self.request_frame_with(comp, frame, scale, frames::Frames::request); } /// Request the frame on screen: rendered before any queued prefetch. pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, true); + self.request_frame_with(comp, frame, scale, frames::Frames::request_urgent); + } + + /// Request a frame nobody waits for (Cache Frames When Idle): rendered on one thread + /// (`Frames::request_background`). + pub fn request_frame_background(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, frames::Frames::request_background); } /// Render options of viewer frames of `comp` at `scale`. @@ -152,15 +158,17 @@ impl EffectcraftApp { } } - fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { + fn request_frame_with( + &self, + comp: ItemId, + frame: i64, + scale: f64, + request: fn(&frames::Frames, &frames::RenderSource, FrameKey, ItemId, Tick, RenderOpts), + ) { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); let opts = self.frame_opts(comp, scale); - if urgent { - self.frames.request_urgent(&self.render_source(), key, comp, t, opts); - } else { - self.frames.request(&self.render_source(), key, comp, t, opts); - } + request(&self.frames, &self.render_source(), key, comp, t, opts); } } diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index 10a53f1d2..7326b9f32 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -56,6 +56,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..fbfe4b8af 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -532,7 +532,7 @@ mod tests { let item = p.add_item("long.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "long.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = RenderSource { project: Arc::new(p), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e535cc723..f67591bee 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.inspect`: UI state + summary; `ui.elements {prefix?}`: registered widgets (id, label, rect) //! - `ui.set {tool?, workspace?, theme?, focused?, viewer?: {zoom, res, pan, grid, …}, timeline?: {pps, start, graphEditor, showModes}}` //! - `ui.panel.show {panel}` / `ui.panel.close {panel}` -//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` +//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy,unit?,source?,pinch?}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` //! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point @@ -500,8 +500,9 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } -/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's -/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. +/// Queues a move to the target point, a wheel event of `dx`, `dy` (`unit`: points or lines) with +/// the request's modifiers, a pinch's zoom factor, and a released Extra1 button. Replies +/// [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -509,14 +510,23 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + // Without a `source`, a scroll in lines is a mouse wheel and one in points comes from an + // unnamed device, as on Windows, Linux and the web. + let source = match p.get("source").and_then(Value::as_str) { + Some("trackpad") => egui::MouseWheelSource::Trackpad, + Some("wheel") => egui::MouseWheelSource::Wheel, + _ if line => egui::MouseWheelSource::Wheel, + _ => egui::MouseWheelSource::Unknown, + }; app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(dx, dy), - modifiers: modifiers(p), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - }); + if dx != 0.0 || dy != 0.0 { + app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, source }); + } + if let Some(pinch) = p.get("pinch").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0) { + app.synthetic.push(egui::Event::Zoom(pinch as f32)); + } app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(-100.0, -100.0), button: egui::PointerButton::Extra1, diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 6704017b9..f2dc3975d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 054aa3598..8e8fd5eca 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -531,7 +531,8 @@ pub enum DocAction { const TAB_PADDING: f32 = 24.0; /// Width of the » button that lists the comp tabs with no room in the strip. const DOC_OVERFLOW: f32 = 22.0; -/// Room for the × after the shown comp's name. +/// Room for the × after a comp's name. Every comp tab keeps it, so a tab keeps its width when it +/// is shown or hovered. const DOC_CLOSE: f32 = 16.0; /// A comp tab's text: the label swatch, then the name, as the panel tabs with a swatch show it. @@ -547,24 +548,46 @@ fn text_width(ctx: &egui::Context, job: egui::text::LayoutJob) -> f32 { ctx.fonts_mut(|f| f.layout_job(job)).size().x } -/// Whether comp tab `doc` carries the ×: the comp the Timeline shows, while it is the group's -/// shown panel. -fn has_close(doc: &DocTab, selected: bool) -> bool { - selected && doc.active +/// The width each comp tab asks for in the Timeline group's tab row. +pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], font: &egui::FontId) -> Vec { + docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + DOC_CLOSE).collect() } -/// The width each comp tab asks for in the Timeline group's tab row. -pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], selected: bool, font: &egui::FontId) -> Vec { - docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + if has_close(d, selected) { DOC_CLOSE } else { 0.0 }).collect() +/// The × of comp tab `doc` at `rect`, clipped to `clip`; true when clicked. `shown` is the comp +/// the Timeline shows while it is the group's shown panel. Automation: +/// `panel.tab.Timeline..close`, and `panel.tab.Timeline.close` for the shown comp's. +fn doc_close( + ui: &mut egui::Ui, + rect: Rect, + clip: Rect, + doc: &DocTab, + shown: bool, + style: &craft_ui::docking::DockStyle, + reg: &mut crate::automation::Registry, +) -> bool { + let timeline = PanelKind::Timeline.id(); + let hit = ui.interact(rect.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); + let cc = if hit.hovered() { style.text } else { style.inactive_text }; + let k = 3.5; + let painter = ui.painter().with_clip_rect(clip); + painter.line_segment([rect.center() + vec2(-k, -k), rect.center() + vec2(k, k)], Stroke::new(1.2, cc)); + painter.line_segment([rect.center() + vec2(-k, k), rect.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + reg.add_response(&format!("panel.tab.{timeline}.{}.close", doc.id), &hit, "Close"); + if shown { + reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); + } + hit.clicked() } /// Draw the Timeline's comp tabs into `strip`, the part of the Timeline group's tab row after its -/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, and × on the -/// shown comp while the Timeline is the group's shown panel (`selected`). Tabs that do not fit go -/// to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and Shift+. step -/// through every open comp. Automation: `panel.tab.Timeline.`, `panel.tab.Timeline.close` -/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` -/// for its items. +/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, then a × +/// that closes the comp's Timeline: always on the shown comp while the Timeline is the group's +/// shown panel (`selected`), on the other comp tabs while the pointer is over them. Tabs that do +/// not fit go to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and +/// Shift+. step through every open comp. Automation: `panel.tab.Timeline.`, the ×s (see +/// [`doc_close`]), `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` for its +/// items. pub fn doc_tabs( ui: &mut egui::Ui, strip: Rect, @@ -575,7 +598,7 @@ pub fn doc_tabs( ) -> Vec { let mut out = Vec::new(); let timeline = PanelKind::Timeline.id(); - let widths = doc_widths(ui.ctx(), docs, selected, &style.font); + let widths = doc_widths(ui.ctx(), docs, &style.font); let active = docs.iter().position(|d| d.active).unwrap_or(0); let fit = craft_ui::tabs::fit_whole(&widths, active, strip.width(), DOC_OVERFLOW); let clip = strip.intersect(ui.clip_rect()); @@ -587,10 +610,9 @@ pub fn doc_tabs( x += width; let on = selected && doc.active; let color = if on { style.text } else { style.inactive_text }; - let close = has_close(doc, selected); let mut job = doc_job(doc, &style.font, color); job.wrap = egui::text::TextWrapping { - max_width: (tab.width() - TAB_PADDING - if close { DOC_CLOSE } else { 0.0 }).max(0.0), + max_width: (tab.width() - TAB_PADDING - DOC_CLOSE).max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}'), @@ -608,17 +630,10 @@ pub fn doc_tabs( ); let resp = if galley.elided { resp.on_hover_text(&doc.title) } else { resp }; reg.add_response(&format!("panel.tab.{timeline}.{}", doc.id), &resp, &doc.title); - if close { + // After the tab, so the × takes the clicks over it. + if on || ui.rect_contains_pointer(tab.intersect(clip)) { let cr = Rect::from_center_size(pos2(label_end + DOC_CLOSE / 2.0, mid), vec2(10.0, 10.0)); - let hit = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); - hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); - let cc = if hit.hovered() { style.text } else { style.inactive_text }; - let k = 3.5; - let painter = ui.painter().with_clip_rect(tab.intersect(clip)); - painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); - painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); - reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); - if hit.clicked() { + if doc_close(ui, cr, tab.intersect(clip), doc, on, style, reg) { out.push(DocAction::Close(doc.id)); } } @@ -654,7 +669,8 @@ pub fn doc_tabs( out } -/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked). +/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked): a +/// closed padlock while locked, an open one while unlocked. /// Returns true when clicked. Automation: `panel.tab..lock`. pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool, t: &Tokens, reg: &mut crate::automation::Registry) -> bool { let resp = ui.interact(rect, egui::Id::unique(("tab-lock", panel.id())), Sense::click()); @@ -665,7 +681,7 @@ pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool } else { t.text_faint }; - icons::paint(ui.painter(), rect.shrink(2.0), Icon::Lock, color); + icons::paint(ui.painter(), rect.shrink(2.0), if locked { Icon::Lock } else { Icon::LockOpen }, color); reg.add_response(&format!("panel.tab.{}.lock", panel.id()), &resp, if locked { "Unlock" } else { "Lock" }); crate::widgets::focus_outline(ui, &resp, t); resp.clicked() diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 30710392a..483dcf4bf 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -86,6 +86,13 @@ fn panel_param(value: &Value) -> Result { } } +fn check_raw_action(action: &Action) -> Result<(), String> { + if matches!(action, Action::Open { .. } | Action::OpenAt { .. }) { + return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); + } + Ok(()) +} + /// The docking commands: `window.dockAction {action}`, craft-ui's `window.panel.*` commands, /// `ui.dock` and `ui.dock.reset`. `None` for any other id. pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -93,9 +100,7 @@ pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, par "window.dockAction" => Some((|| { let value = params.get("action").cloned().ok_or("missing docking action")?; let action: Action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; - if matches!(&action, Action::Open { .. } | Action::OpenAt { .. }) { - return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); - } + check_raw_action(&action)?; app.apply_dock(action)?; Ok(Value::Null) })()), @@ -153,6 +158,9 @@ impl EffectcraftApp { /// Run a craft-ui docking command on the current workspace, all or nothing. Named /// workspaces and Reset are EffectCraft's own (`window.workspace`, `window.resetWorkspace`). pub(crate) fn run_dock_command(&mut self, ctx: &egui::Context, command: DockCommand) -> Result<(), String> { + if let DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) = &command { + check_raw_action(action)?; + } if command.is_app_owned() { if matches!(command, DockCommand::ResetWorkspace {}) { let name = self.ui.workspace.clone(); @@ -791,7 +799,11 @@ impl DockContent for EffectContent<'_> { } fn logical_label(&self, panel: &PanelKind) -> String { - crate::i18n::command_label("window.panel", panel.title()).to_string() + let title = panel.title(); + // The Window menu names most panels; `Markers` is only in the panel catalog, and the two + // panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::command_label("window.panel", title); + if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() } } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its @@ -835,11 +847,11 @@ impl DockContent for EffectContent<'_> { } } - fn group_extra_tabs_width(&self, panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 { + fn group_extra_tabs_width(&self, panels: &[PanelKind], _active: &PanelKind, _floating: bool) -> f32 { if !self.has_doc_tabs(panels) { return 0.0; } - dock::doc_widths(&self.ctx, &self.docs, *active == PanelKind::Timeline, &self.style.font).iter().sum() + dock::doc_widths(&self.ctx, &self.docs, &self.style.font).iter().sum() } /// The Timeline's comp tabs, after its group's panel tabs whichever panel is shown. diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a7023a494..883cbf172 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1,7 +1,8 @@ //! Background frame rendering and the RAM preview cache. //! //! Frames render on a dedicated thread pool from immutable `Arc` snapshots, so the UI -//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's +//! never waits for the compositor (a playing preview's frames and idle caching on one thread +//! each, so the frames started first are done first). Results land in a memory-budgeted cache keyed by the comp's //! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render //! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames //! of every comp it doesn't touch, and undo finds the frames of the state it returns to. @@ -31,7 +32,7 @@ use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Pro use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; -use effectcraft_gpu::{DisplayFrame, Gpu}; +use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; use rayon::prelude::*; /// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of. @@ -317,23 +318,6 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, project, |_, c| c) } -/// Whether two versions of a comp draw the same pixels. Markers only count when an expression -/// could read them (`markers_count`). -fn same_pixels(a: &Comp, b: &Comp, markers_count: bool) -> bool { - if markers_count { a.same_pixels(b) } else { a.same_pixels_ignoring_markers(b) } -} - -/// Whether a layer anywhere in the project has an expression, which can read markers. -fn project_has_expressions(project: &Project) -> bool { - fn has_expression(g: &PropGroup) -> bool { - g.children.iter().any(|c| match c { - Node::Prop(p) => p.expr.is_some(), - Node::Group(sub) => has_expression(sub), - }) - } - project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props)))) -} - /// [`comp_content`] with each comp's version selected for both its sources and its address. /// Sources removed by undo are read from `base`. fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc) -> &'a Arc) -> u64 { @@ -473,6 +457,10 @@ pub struct Frames { #[cfg(not(target_arch = "wasm32"))] /// None if the OS refused the threads: jobs then go to rayon's global pool. pool: Option, + #[cfg(not(target_arch = "wasm32"))] + /// One thread each, for a playing preview's frames and idle caching (see [`Queue::idle_lanes`]). Empty if + /// the OS refused the threads: those frames then render on `pool` too. + lanes: Vec, ctx: Option, /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, @@ -501,10 +489,25 @@ impl Default for Frames { fn default() -> Self { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..threads) + .map_while(|i| { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .thread_name(move |_| format!("ec-preview-{i}")) + .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) + .build() + .ok() + }) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + #[cfg(target_arch = "wasm32")] + let queue = Queue::default(); Frames { cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), - queue: Arc::new(Mutex::new(Queue::default())), + queue: Arc::new(Mutex::new(queue)), #[cfg(not(target_arch = "wasm32"))] // A panicking job would abort the whole process (rayon's default for `spawn`): log it // and lose only that frame (the panic hook has logged the message). @@ -514,6 +517,8 @@ impl Default for Frames { .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() .ok(), + #[cfg(not(target_arch = "wasm32"))] + lanes, ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), @@ -762,12 +767,12 @@ impl Frames { fn identity(&self, project: &Arc, comp: ItemId) -> Identity { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; - // Markers can only change pixels through expressions, which read them. - let markers_count = project_has_expressions(project); + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, _ => c, }) == *base_id { @@ -779,7 +784,7 @@ impl Frames { if let Some((base_id, base)) = bases.get(&comp.0) && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) && comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b, _ => c, }) == *base_id && let Some((s, e)) = new.changed_span(old) @@ -928,26 +933,77 @@ impl Frames { c.playhead = playhead; } let Ok(mut q) = self.queue.lock() else { return }; + // A preview starting or stopping moves the queued prefetch frames between the lanes and + // the pool (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + let moved = q.playhead.is_some() != playhead.is_some(); q.playhead = playhead; - let Some(p) = playhead else { return }; - let Ok(mut inf) = self.inflight.lock() else { return }; - q.jobs.retain(|j| { - let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); - if gone { - inf.remove(&j.key); + if let Some(p) = playhead + && let Ok(mut inf) = self.inflight.lock() + { + q.jobs.retain(|j| { + let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); + if gone { + inf.remove(&j.key); + } + !gone + }); + } + #[cfg(not(target_arch = "wasm32"))] + if moved { + self.start_lanes(&mut q, usize::MAX); + let pool_jobs = q.jobs.iter().filter(|j| !q.on_lane(j)).count().min(self.parallelism()); + drop(q); + for _ in 0..pool_jobs { + self.start_pool(); } - !gone - }); + } + } + + /// Start up to `n` idle lanes on the queued jobs (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + fn start_lanes(&self, q: &mut Queue, n: usize) { + if self.remote.is_some() { + return; + } + let jobs = q.jobs.iter().filter(|j| q.on_lane(j)).count(); + for _ in 0..n.min(jobs) { + let Some(lane) = q.idle_lanes.pop() else { break }; + let Some(pool) = self.lanes.get(lane) else { break }; + let w = self.worker(); + pool.spawn(move || w.drain(Some(lane))); + } + } + + /// Have a pool thread render the queued jobs that are the pool's. + #[cfg(not(target_arch = "wasm32"))] + fn start_pool(&self) { + if self.remote.is_some() { + return; + } + let w = self.worker(); + let job = move || w.drain(None); + match &self.pool { + Some(pool) => pool.spawn(job), + None => rayon::spawn(job), + } } /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { - self.request_with(src, key, comp, t, opts, false); + self.request_with(src, key, comp, t, opts, Want::Prefetch); + } + + /// Queue a frame nobody waits for (Cache Frames When Idle): as [`Frames::request`], but it + /// renders on a lane, one thread, and leaves the pool to the viewer's frame (see + /// [`Queue::idle_lanes`]). + pub fn request_background(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, Want::Background); } /// Queue the frame the viewer is showing: it jumps ahead of every prefetch job. pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { - self.request_with(src, key, comp, t, opts, true); + self.request_with(src, key, comp, t, opts, Want::Viewer); } /// An urgent (viewer) frame is queued or rendering. @@ -955,7 +1011,8 @@ impl Frames { self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false) } - fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { + fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, want: Want) { + let urgent = want == Want::Viewer; // The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at // another zoom must not evict the active viewer's frames. let cached = self @@ -1007,22 +1064,32 @@ impl Frames { } } if !inf.insert(key) { + drop(inf); + drop(q); + // Queued for a lane and now the viewer's frame: it is the pool's. + #[cfg(not(target_arch = "wasm32"))] + if urgent { + self.start_pool(); + } return; } q.seq += 1; let seq = q.seq; - q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq, cancel: Arc::default() }); + let job = Job { key, src: src.clone(), comp, t, opts, urgent, background: want == Want::Background, seq, cancel: Arc::default() }; + // A lane's frame: a busy lane takes the next job when it is done, an idle one starts now. + #[cfg(not(target_arch = "wasm32"))] + let on_lane = q.on_lane(&job); + q.jobs.push(job); + #[cfg(not(target_arch = "wasm32"))] + if on_lane { + self.start_lanes(&mut q, 1); + } drop(inf); drop(q); + // One spawn per job; each spawn renders whichever queued jobs matter most right now. #[cfg(not(target_arch = "wasm32"))] - if self.remote.is_none() { - let w = self.worker(); - // One spawn per job; each spawn renders whichever queued jobs matter most right now. - let job = move || w.drain(); - match &self.pool { - Some(pool) => pool.spawn(job), - None => rayon::spawn(job), - } + if !on_lane { + self.start_pool(); } #[cfg(target_arch = "wasm32")] if let Some(ctx) = &self.ctx { @@ -1164,7 +1231,7 @@ impl Frames { } let t0 = web_time::Instant::now(); let w = self.worker(); - while w.run_next() { + while w.run_next(None) { if t0.elapsed() >= budget { break; } @@ -1212,22 +1279,29 @@ impl Worker { /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn /// that lands inside a render returns at once instead; the render below it takes the queued /// jobs when it is done, so each thread renders one frame at a time. + /// + /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] - fn drain(&self) { + fn drain(&self, lane: Option) { if DRAINING.get() { return; } DRAINING.set(true); let _reset = Draining; - while self.run_next() {} + while self.run_next(lane) {} } - /// Render the queued job that matters most right now. `false` when the queue was empty. - fn run_next(&self) -> bool { + /// Render the queued job that matters most right now, on lane `lane` (`None`: on + /// the pool, or the UI thread). `false` when the queue had no job for it; the lane is then + /// idle again. + fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.best() else { return false }; + let Some(i) = q.next(lane.is_some()) else { + q.idle_lanes.extend(lane); + return false; + }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -1318,11 +1392,16 @@ impl Worker { /// A failed scoped attempt retries on CPU. Retired contexts cannot recover by reducing /// their budget; only a healthy device's allocation failure uses the upstream backoff. - fn gpu_attempt_failed(&self, gpu: &Gpu, e: &str) { + fn gpu_attempt_failed(&self, gpu: &Gpu, e: &FrameError) { if gpu.context().check_health().is_err() { log::warn!("the GPU context is retired ({e}); the frame renders on the CPU"); return; } + // Not memory pressure: keeping fewer GPU frames wouldn't help (#1319). + if let FrameError::Validation(_) = e { + log::warn!("the GPU rejected the frame ({e}); it renders on the CPU, the next frame tries the GPU again"); + return; + } log::warn!("the GPU ran out of memory ({e}); the frame renders on the CPU and fewer GPU frames are kept"); if let Ok(mut c) = self.cache.lock() { c.gpu_out_of_memory(); @@ -1417,6 +1496,14 @@ impl Worker { } } +/// Who asked for a frame (see [`Frames::request_with`]). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Want { + Viewer, + Prefetch, + Background, +} + struct Job { key: FrameKey, src: RenderSource, @@ -1424,6 +1511,8 @@ struct Job { t: Tick, opts: RenderOpts, urgent: bool, + /// Nobody waits for it (see [`Frames::request_background`]). + background: bool, seq: u64, /// Set when the viewer no longer waits for this frame (see [`Frames::request_urgent`]). cancel: Arc, @@ -1446,18 +1535,45 @@ struct Queue { seq: u64, /// The playing preview, if any: its prefetch renders nearest first. playhead: Option, + /// How many lanes there are, and the ones not rendering. A lane is one thread that renders + /// one frame at a time. The frames of a playing preview and the frames nobody waits for + /// (Cache Frames When Idle) render on the lanes; the pool keeps the viewer's frame and the + /// few frames prefetched around it while paused. On the pool a frame's parallel work is + /// shared: a thread that took a piece of one frame and waited inside it started rendering + /// the queued frames on top, and went back to its piece only once the queue was empty. The + /// frames a preview started first, the ones right ahead of the playhead, were done last: + /// the preview stood still on a gap while the frames further ahead turned green. Idle + /// caching left the same gaps, and held up the viewer's frame when it shared a piece. + lanes: usize, + idle_lanes: Vec, } impl Queue { + /// Whether `j` renders on a lane (else on the pool). + fn on_lane(&self, j: &Job) -> bool { + self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) + } + + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. + fn next(&self, lane: bool) -> Option { + self.best_of(|j| self.on_lane(j) == lane) + } + /// The job that matters most: urgent first (newest), then, while a preview plays, the /// prefetch frame it reaches first, then prefetch in request order. In request order alone, /// frames asked for again after an eviction (the ones right ahead of the playhead) waited /// behind every frame further ahead (#595). fn best(&self) -> Option { + self.best_of(|_| true) + } + + /// [`Queue::best`] among the jobs `of` accepts. + fn best_of(&self, of: impl Fn(&Job) -> bool) -> Option { let p = self.playhead.as_ref(); self.jobs .iter() .enumerate() + .filter(|(_, j)| of(j)) .max_by_key(|(_, j)| { let near = if j.urgent { None } else { p.and_then(|p| p.ahead(&j.key)) }; (j.urgent, near.is_some(), near.map_or(0, |d| -d), if j.urgent { j.seq as i64 } else { -(j.seq as i64) }) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 6da1dc269..49f77a6cf 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -437,7 +437,9 @@ fn a_newer_viewer_frame_drops_the_ones_still_waiting() { gpu_display: false, disk: None, }; - let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent); + let request = |k: FrameKey, urgent: bool| { + f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), if urgent { Want::Viewer } else { Want::Prefetch }) + }; let other = FrameKey { comp: 2, ..key(1, 0, 0) }; request(other, true); request(key(1, 20, 0), false); @@ -558,6 +560,131 @@ fn prefetch_renders_the_frames_the_playhead_reaches_first() { assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); } +/// A decoder that works in parallel, as the renderer does, in a comp that shows it. +#[derive(Default)] +struct ParallelClip { + started: std::sync::atomic::AtomicUsize, + /// Frames started by the time frame 1 was done. + started_at_first: std::sync::atomic::AtomicUsize, +} + +impl FootageSource for ParallelClip { + fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { + self.started.fetch_add(1, Ordering::SeqCst); + (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); + if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { + self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); + } + Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) + } +} + +/// A 10 s comp of a [`ParallelClip`]: the frame cache, the clip, the render source, the comp +/// and the key of its frame 0. +fn parallel_clip() -> (Frames, Arc, RenderSource, ItemId, FrameKey) { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(ParallelClip::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let series = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), comp: cid.0, frame: 0, scale: 1000, view: 0, opts: 0 }; + (f, footage, src, cid, series) +} + +fn wait_for_renders(f: &Frames) { + for _ in 0..8000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); +} + +/// A playing preview's frames render side by side, and each works in parallel: a render +/// waiting for its own parallel work took over pieces of the frames started after it, and +/// its frame was done only once theirs were. The frames right ahead of the playhead, started +/// first, came last: the preview stood still while the frames further ahead turned green. +/// Cache Frames When Idle left the same gaps. +#[test] +fn frames_started_first_are_done_first() { + use effectcraft_engine::time::FrameRate; + for playing in [true, false] { + let (f, footage, src, cid, series) = parallel_clip(); + let frames = 60; + if playing { + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + } + for frame in 1..=frames { + let (key, t) = (FrameKey { frame, ..series }, FrameRate::FPS_30.tick_of(frame)); + if playing { + f.request(&src, key, cid, t, RenderOpts::default()); + } else { + f.request_background(&src, key, cid, t, RenderOpts::default()); + } + if frame == 1 { + // Its parallel work has started (and idle threads have taken pieces of it). + std::thread::sleep(std::time::Duration::from_millis(3)); + } + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + let before = footage.started_at_first.load(Ordering::SeqCst); + assert!(before <= 2 * f.parallelism(), "{before} frames were started before the first one was done (playing: {playing})"); + } +} + +/// Prefetch frames queued when a preview starts or stops, or that become the viewer's +/// frame, still render: they change hands between the lanes and the pool. +#[test] +fn queued_frames_render_when_a_preview_starts_or_stops() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + let playhead = Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false }; + let request = |frames: std::ops::Range| { + for frame in frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + }; + // Queued for the pool, then a preview starts. + request(1..40); + f.set_playhead(Some(playhead)); + // Queued for the lanes, then the preview stops. + request(40..80); + f.set_playhead(None); + wait_for_renders(&f); + // Queued for the lanes, then the viewer shows one of them. + for frame in 80..120 { + f.request_background(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + f.request_urgent(&src, FrameKey { frame: 119, ..series }, cid, FrameRate::FPS_30.tick_of(119), RenderOpts::default()); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 119); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the @@ -895,33 +1022,59 @@ fn adding_a_source_does_not_hide_changes_to_existing_dependencies() { } } +/// Adding, moving or deleting a marker (Num* while a preview plays) keeps the cached frames: +/// the sound would otherwise stop at the next frame that has to be rendered again (#1083). +/// Only expressions that read markers make them part of the pixels. #[test] -fn markers_keep_the_frames() { +fn markers_keep_the_frames_unless_an_expression_reads_them() { use effectcraft_engine::Session; use serde_json::json; let mut s = Session::default(); let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); - let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); - let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); + let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer"); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer"); let f = Frames::default(); - let at = |s: &Session| f.content_of(&s.project, s.revision, outer); - let o0 = at(&s); - s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); - assert_eq!(at(&s), o0, "a marker changes no pixels, so it keeps the frames"); + let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c); + let (o0, i0) = (at(&s, outer), at(&s, inner)); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 0.5, "comment": "beat"})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.0})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.5})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "comp markers"); + s.execute("markers.set", json!({"comp": inner.0, "layer": solid, "new": true, "time": 0.25})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "layer marker"); + // An expression reading markers makes them count again. + s.execute("prop.setExpression", json!({"comp": inner.0, "layer": solid, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let (o1, i1) = (at(&s, outer), at(&s, inner)); + assert_ne!(o1, o0); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + assert_ne!(at(&s, inner), i1, "marker read by an expression"); + assert_ne!(at(&s, outer), o1, "marker read by an expression in a nested comp"); } +/// A protected time marker with a duration sets the comp's timing (Responsive Design), so it +/// changes the frames even though plain markers do not; and any expression, which can read +/// markers, makes every marker count. #[test] -fn markers_count_when_an_expression_can_read_them() { +fn timing_markers_and_expressions_still_change_the_frames() { use effectcraft_engine::Session; use serde_json::json; let mut s = Session::default(); let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp")); let solid = id(s.execute("layer.newSolid", json!({"comp": outer.0, "color": "#ff0000"})).unwrap(), "layer"); - s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap(); let f = Frames::default(); let at = |s: &Session| f.content_of(&s.project, s.revision, outer); let o0 = at(&s); s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.5, "comment": "beat"})).unwrap(); - assert_ne!(at(&s), o0, "an expression can read the marker, so the frames change"); + assert_eq!(at(&s), o0, "a plain marker changes no pixels, so it keeps the frames"); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 0.25})).unwrap(); + s.execute("markers.set", json!({"comp": outer.0, "index": 0, "duration": 0.25, "protected": true})).unwrap(); + let o1 = at(&s); + assert_ne!(o1, o0, "a protected time marker with a duration sets the timing"); + s.execute("prop.setExpression", json!({"comp": outer.0, "layer": solid, "path": "transform/opacity", "expression": "1"})).unwrap(); + let o2 = at(&s); + s.execute("layer.addMarker", json!({"comp": outer.0, "layers": [solid], "time": 0.75})).unwrap(); + assert_ne!(at(&s), o2, "an expression can read the marker, so the frames change"); } diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 3eee89453..8710f1f9a 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,6 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; +use effectcraft_engine::geom::vec2 as gv2; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; @@ -93,9 +94,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: )); } app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + // The drawn tooltip asks the catalog; the automation label above keeps the English name. + let label = crate::i18n::tr(cur.label()); let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), + Some(s) => format!("{} ({})", label, crate::menus::shortcut_text(s)), + None => label.to_string(), }; let resp = resp.on_hover_text(tip); let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); @@ -109,6 +112,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) { app.ui.status = e; + } else if resp.double_clicked() + && let Some((id, params)) = create_on_double_click(app, cur) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) + { + app.ui.status = e; } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); @@ -139,7 +147,7 @@ fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[T let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + let row = ui.selectable_label(app.ui.tool == *tool, crate::i18n::tr(tool.label())); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); if row.clicked() { app.ui.tool = *tool; @@ -374,6 +382,58 @@ fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } } +/// Double-clicking a creation tool in the Tools bar makes an instance of what it draws, as in +/// After Effects (#1072): a text layer from the Type tools; from the shape tools, a shape that +/// fills the comp — into the selected shape layer's Contents, as a mask on the selected layer +/// (any non-shape layer, or Tool Creates Mask), or as a new shape layer when neither applies. +fn create_on_double_click(app: &EffectcraftApp, tool: Tool) -> Option<(&'static str, serde_json::Value)> { + match tool { + // `invoke` turns empty params into an insertion point in the comp centre that starts + // editing (#866), which is what double-clicking the Type tool should do. + Tool::Type | Tool::TypeVertical => Some(("layer.newText", json!({}))), + Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => { + let kind = match tool { + Tool::Rectangle => "rect", + Tool::RoundedRect => "rounded", + Tool::Ellipse => "ellipse", + Tool::Polygon => "polygon", + _ => "star", + }; + // Without a comp the command still runs and reports its error on the status line. + let Some(comp) = app.session.active_comp() else { + return Some(("shape.newShape", json!({"kind": kind}))); + }; + let (cw, ch) = (comp.width as f64, comp.height as f64); + // Rectangles and ellipses fill the comp; a Polystar's Outer Radius is half the comp + // height (Inner Radius: a quarter of it), so it gets a comp-height square. + let (w, h) = if matches!(tool, Tool::Polygon | Tool::Star) { (ch, ch) } else { (cw, ch) }; + // A mask on the selected layer: any non-shape layer (the tool switches to Mask by + // itself), or a shape layer while Tool Creates Mask is on — as dragging does. + let sel = app.session.state.selected_layers.first().copied().and_then(|id| comp.layer(id)); + if let Some(l) = sel.filter(|l| l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape))) { + // The box in layer space, so a moved, scaled or rotated layer still gets a + // comp-filling mask: all four corners are mapped, and their bounds are the + // smallest axis-aligned box around the comp box. + let ctx = effectcraft_engine::render::EvalCtx::new(&app.session.project, app.session.active_comp_id()?, comp, app.session.time()); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_engine::geom::Mat3::IDENTITY); + let (x0, y0, x1, y1) = (cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0, cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0); + let pts = [gv2(x0, y0), gv2(x1, y0), gv2(x1, y1), gv2(x0, y1)].map(|p| inv.apply(p)); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in pts { + lo = [lo[0].min(p.x), lo[1].min(p.y)]; + hi = [hi[0].max(p.x), hi[1].max(p.y)]; + } + let rect = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; + return Some(("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect}))); + } + // Into the selected shape layer's Contents, else a new shape layer (the engine's own + // target), painted with the Tools bar's Fill and Stroke. + Some(("shape.newShape", json!({"kind": kind, "size": [w, h]}))) + } + _ => None, + } +} + /// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to /// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`). fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) { @@ -834,4 +894,254 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } + + /// Double-clicking a creation tool in the Tools bar makes an instance of what it draws + /// (#1072): with nothing selected a shape layer whose shape fills the comp, into the selected + /// shape layer's Contents, a mask on a non-shape layer, a text layer from the Type tools. + /// Tools that create nothing stay quiet. + #[test] + fn double_clicking_creation_tools_makes_instances() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Both click pairs in one frame, as egui counts a double-click. + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Nothing selected: a shape layer with a Rectangle Path 1 the size of the comp. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("Rectangle Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the rectangle fills the comp"); + } + // The shape layer is selected: the Ellipse goes on top of its Contents, no new layer. + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "into the selected shape layer's Contents"); + let l = &comp.layers[0]; + assert!(l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").is_some(), "the new ellipse is on top"); + assert!(l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").is_some(), "the rectangle still stands"); + } + // The Type tool: a text layer above the selection, editing started (an insertion point, #866). + double_click(&mut app, &ctx, 3.0, "tools.Type"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2); + assert!(matches!(comp.layers[0].source, LayerSource::Text)); + assert!(app.session.state.text_edit.is_some(), "the text layer is being edited"); + } + // A non-shape layer is selected, so the Rectangle becomes a comp-filling mask on it. + app.ui.slot_tools[8] = Tool::Rectangle; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 4.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a mask, not a layer"); + let p = comp.layers[0].props.prop("masks/Mask 1/path").expect("Mask 1"); + let sh = p.value.as_path().expect("a mask path"); + let b = effectcraft_path::bounds(&[effectcraft_path::to_kurbo(sh)]).expect("bounds"); + // The text layer is anchored top-left and centred in the comp: layer = comp - centre. + for (got, want) in [(b.x0, -320.0), (b.y0, -180.0), (b.x1, 320.0), (b.y1, 180.0)] { + assert!((got - want).abs() < 1.0, "the mask fills the comp: {got} vs {want}"); + } + } + // A tool that creates nothing: no layer, no error. + double_click(&mut app, &ctx, 5.0, "tools.Hand"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 2, "the Hand tool creates nothing"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + // Without a comp the command fails with the status line, never a panic. + let mut bare = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &bare.tokens, crate::i18n::language(&bare)); + frame(&mut bare, &ctx, 0.0, vec![]); + frame(&mut bare, &ctx, 0.1, vec![]); + double_click(&mut bare, &ctx, 1.0, "tools.Rectangle"); + assert!(!bare.ui.status.is_empty(), "no comp: a status message, not a crash"); + } + + /// #1765: a double-click on the Ellipse tool with nothing selected makes its own shape layer + /// the size of the comp, as the Rectangle does. + #[test] + fn double_click_ellipse_with_no_selection_makes_a_layer() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.0, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let e = app.auto.elements.iter().find(|w| w.id == "tools.Ellipse").unwrap_or_else(|| panic!("no tools.Ellipse")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(&mut app, &ctx, 1.1, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").expect("Ellipse Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the ellipse fills the comp"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// Tool Creates Mask on (#1072): a double-click on a shape tool masks the selected shape + /// layer instead of adding to its Contents. + #[test] + fn double_click_masks_a_shape_layer_while_tool_creates_mask() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // A shape layer, selected by the double-click that made it. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1); + app.session.state.shape_tool.creates_mask = true; + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "a mask, not a second shape layer"); + let l = &comp.layers[0]; + assert!(l.props.prop("masks/Mask 1/path").is_some(), "an ellipse mask"); + assert!(l.props.prop("contents/Ellipse 1").is_none(), "nothing added to the Contents"); + let p = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("the rectangle stands"); + assert_eq!(p.value.as_vec2(), [640.0, 360.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// The shape tools' geometry defaults (#1072): the Rounded Rectangle tool's Roundness is a + /// fixed 20, the Polygon tool a 5-point polygon, the Star tool a 5-point star whose Inner + /// Radius is a quarter of its Outer Radius. + #[test] + fn double_click_shape_tool_geometry_defaults() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Each shape on its own layer: deselect so the next double-click makes a new one. + let deselected = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64| { + app.session.execute("edit.deselectAll", json!({})).unwrap(); + frame(app, ctx, t, vec![]); + }; + // The Rounded Rectangle: Roundness 20, filling the comp. + app.ui.slot_tools[8] = Tool::RoundedRect; + frame(&mut app, &ctx, 1.0, vec![]); + double_click(&mut app, &ctx, 1.1, "tools.RoundedRect"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents/Rectangle Path 1").expect("Rectangle Path 1"); + assert_eq!(g.prop("roundness").expect("roundness").value.as_f64(), 20.0, "a fixed roundness"); + assert_eq!(g.prop("size").expect("size").value.as_vec2(), [640.0, 360.0]); + } + // The Star: 5 points, Outer Radius half the comp height, Inner Radius a quarter. + deselected(&mut app, &ctx, 2.0); + app.ui.slot_tools[8] = Tool::Star; + frame(&mut app, &ctx, 2.05, vec![]); + double_click(&mut app, &ctx, 2.1, "tools.Star"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 0, "Star"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert_eq!(g.prop("innerRadius").expect("inner").value.as_f64(), 90.0, "a quarter of the comp height"); + } + // The Polygon: 5 points, Outer Radius half the comp height, no inner radius. + deselected(&mut app, &ctx, 3.0); + app.ui.slot_tools[8] = Tool::Polygon; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 3.1, "tools.Polygon"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 1, "Polygon"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert!(g.prop("innerRadius").is_none(), "a polygon has no inner radius"); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// A double-clicked shape is painted with the Tools bar's Fill and Stroke (#1072): the + /// colours and stroke width the shape tool is set to — and no outline at all when the tool + /// has its Stroke off. + #[test] + fn double_click_paints_with_the_shape_tools_fill_and_stroke() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // The tool's own Fill and Stroke go onto the created shape. + app.session.state.shape_tool.fill.color = [1.0, 0.0, 0.0]; + app.session.state.shape_tool.stroke.color = [0.0, 1.0, 0.0]; + app.session.state.shape_tool.stroke_width = 5.0; + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents").expect("the contents"); + let fill = g.sub("fill").expect("the fill"); + assert_eq!(fill.get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0], "the tool's Fill"); + let stroke = g.sub("stroke").expect("the stroke"); + assert_eq!(stroke.get("color").expect("color").value.as_color(), [0.0, 1.0, 0.0, 1.0], "the tool's Stroke"); + assert_eq!(stroke.get("width").expect("width").value.as_f64(), 5.0, "the tool's Stroke Width"); + } + // Stroke off ("Keine"): the next shape gets no outline at all, but keeps the Fill. + app.session.execute("shape.toolOptions", json!({"stroke": false})).unwrap(); + app.session.execute("edit.deselectAll", json!({})).unwrap(); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a new shape layer"); + let g = comp.layers[0].props.group("contents/Ellipse 1/contents").expect("the ellipse"); + assert!(g.sub("stroke").is_none(), "no outline when the tool's Stroke is off"); + assert_eq!(g.sub("fill").expect("the fill").get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8d7dcb704..457aeb99a 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -62,6 +62,10 @@ pub struct Hooks { /// Tells the user the graphics device was lost and the window is closing, once. `saved` is /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. pub device_lost: Option>)>>, + /// Runs on the worker thread right before the GPU compositor is built (desktop). Tests hold + /// it to look at the interface while the compositor is still being prepared, or make it + /// panic to check that a failing build never reaches the UI thread. + pub before_gpu_build: Option>, } impl Hooks { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 611a226ee..14afe59cc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -15,7 +15,7 @@ pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 11] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; +const CATALOG_CODES: [&str; 12] = ["cs", "de", "es", "fr", "pl", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -66,6 +66,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Polish. +pub(crate) fn polish(app: &EffectcraftApp) -> bool { + language(app) == "pl" +} + /// The UI is in Georgian. pub(crate) fn georgian(app: &EffectcraftApp) -> bool { language(app) == "ka" @@ -123,7 +128,7 @@ fn web_draws(language: &str) -> bool { /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, /// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`, -/// `ka-GE` → `ka`). +/// `ka-GE` → `ka`, `ru-RU` → `ru`, `de-AT` → `de`, `fr-CA` → `fr`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -134,7 +139,11 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "pl" => "pl", "ka" => "ka", + "ru" => "ru", + "de" => "de", + "fr" => "fr", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -170,6 +179,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "pl" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Pokaż w Eksploratorze", + "Reveal in File Manager" => return "Pokaż w menedżerze plików", + _ => {} + } + } if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { match source { "Reveal in Explorer" => return "Explorer-ში ჩვენება", @@ -193,6 +209,7 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "pl" => POLISH, "ka" => GEORGIAN, _ => return None, }) @@ -252,22 +269,35 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin return effects::name(id, &e.label); } // Undo/redo names come from the history, so they are formatted per language here rather than - // looked up in a catalog. An English (or untranslated) interface keeps the shown label. - let (undo, no_undo, redo, no_redo) = match language(app) { - "ja" => ("取り消し", "取り消しできません", "やり直し", "やり直しできません"), - "zh-hans" => ("撤销", "无可撤销的操作", "重做", "无可重做的操作"), - "zh-hant" => ("復原", "沒有可復原的操作", "重做", "沒有可重做的操作"), - "uk" => ("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення"), - "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), - "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), - "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + // looked up in a catalog. A language without this wording keeps the shown undo/redo label, and + // the rest of its menu still goes through the catalog below (#1449). + let history = match language(app) { + "en" => return shown, + "ja" => Some(("取り消し", "取り消しできません", "やり直し", "やり直しできません")), + "zh-hans" => Some(("撤销", "无可撤销的操作", "重做", "无可重做的操作")), + "zh-hant" => Some(("復原", "沒有可復原的操作", "重做", "沒有可重做的操作")), + "uk" => Some(("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення")), + "es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")), + "pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")), + "cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")), + "fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")), + "de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")), + "pl" => Some(("Cofnij", "Nie można cofnąć", "Ponów", "Nie można ponowić")), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". - "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), - _ => return shown, + "ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")), + // A colon before the history's (untranslated) action name: "Отменить: Comp1". + "ru" => Some(("Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять")), + _ => None, }; match e.command.as_str() { - "edit.undo" => app.session.history.undo.last().map(|u| format!("{undo} {}", u.0)).unwrap_or_else(|| no_undo.into()), - "edit.redo" => app.session.history.redo.last().map(|u| format!("{redo} {}", u.0)).unwrap_or_else(|| no_redo.into()), + "edit.undo" | "edit.redo" => { + let Some((undo, no_undo, redo, no_redo)) = history else { return shown }; + if e.command == "edit.undo" { + app.session.history.undo.last().map(|u| format!("{undo} {}", u.0)).unwrap_or_else(|| no_undo.into()) + } else { + app.session.history.redo.last().map(|u| format!("{redo} {}", u.0)).unwrap_or_else(|| no_redo.into()) + } + } "window.panel" => { let prefix = format!("{}: ", e.label); if let Some(name) = shown.strip_prefix(&prefix) { @@ -293,8 +323,23 @@ pub(crate) fn effect_category(children: &[MenuNode], source: &str) -> Option Option { + let named = shown.strip_prefix("Assign Shortcut to “")?; + match source { + "Assign Shortcut to Workspace" => named.strip_suffix("” Workspace").map(|name| format!("Przypisz skrót do obszaru roboczego „{name}”")), + "Assign Shortcut to 3D View" => named.strip_suffix('”').map(|name| format!("Przypisz skrót do widoku 3D „{name}”")), + _ => None, + } +} + pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "pl" => { + if let Some(label) = polish_assign_shortcut(source, &shown) { + return label; + } + } "cs" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -823,6 +868,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), + ("keys.setMuted", "Mute Keyframes", "静音关键帧"), + ("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1459,6 +1506,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), + ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), + ("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2092,6 +2141,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), + ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2728,6 +2779,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"), ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), + ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), + ("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3365,6 +3418,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), + ("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4000,6 +4055,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), + ("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4642,6 +4699,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), + ("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5277,6 +5336,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), + ("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5912,6 +5973,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), + ("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6111,6 +6174,640 @@ const GERMAN: &[(&str, &str, &str)] = &[ // <<< generated i18n catalogs +/// Polish (pl): original phrase translations of every fixed menu entry. +const POLISH: &[(&str, &str, &str)] = &[ + ("", "Front", "Przód"), + ("", "Bevel", "Fazowanie"), + ("", "Side", "Bok"), + ("", "Back", "Tył"), + ("layer.addTextAnimator", "Color", "Kolor"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O programie EffectCraft..."), + ("", "Settings...", "Ustawienia..."), + ("app.settings", "General...", "Ogólne..."), + ("app.settings", "Startup & Repair...", "Uruchamianie i naprawa..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozycja..."), + ("app.settings", "Previews...", "Podglądy..."), + ("app.settings", "Appearance...", "Wygląd..."), + ("app.settings", "Grids & Guides...", "Siatki i linie pomocnicze..."), + ("app.settings", "Labels...", "Etykiety..."), + ("app.settings", "Type...", "Tekst..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Eksport..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Dysk..."), + ("app.settings", "Memory & CPU...", "Pamięć i procesor..."), + ("app.settings", "Video...", "Wideo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skrypty i wyrażenia..."), + ("app.hide", "Hide EffectCraft", "Ukryj EffectCraft"), + ("app.hideOthers", "Hide Others", "Ukryj pozostałe"), + ("app.showAll", "Show All", "Pokaż wszystko"), + ("app.quit", "Quit EffectCraft", "Zakończ EffectCraft"), + ("", "File", "Plik"), + ("", "New", "Nowy"), + ("file.newProject", "New Project", "Nowy projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nowy projekt z szablonu..."), + ("project.newFolder", "New Folder", "Nowy folder"), + ("file.open", "Open Project...", "Otwórz projekt..."), + ("", "Open Recent", "Otwórz ostatnie"), + ("file.clearRecent", "Clear Recent Projects", "Wyczyść ostatnie projekty"), + ("file.close", "Close", "Zamknij"), + ("file.closeProject", "Close Project", "Zamknij projekt"), + ("file.save", "Save", "Zapisz"), + ("", "Save As", "Zapisz jako"), + ("file.saveAs", "Save As...", "Zapisz jako..."), + ("file.saveCopy", "Save a Copy...", "Zapisz kopię..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Zapisz kopię jako XML..."), + ("templates.saveAs", "Save as Template...", "Zapisz jako szablon..."), + ("file.incrementAndSave", "Increment and Save", "Zapisz z kolejnym numerem"), + ("file.revert", "Revert", "Przywróć"), + ("", "Import", "Importuj"), + ("file.import", "File...", "Plik..."), + ("file.importMultiple", "Multiple Files...", "Wiele plików..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), + ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.importSolid", "Solid...", "Plansza..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Punkt zbiegu (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("", "Import Recent Footage", "Importuj ostatnie materiały"), + ("file.clearRecentFootage", "Clear Recent Footage", "Wyczyść ostatnie materiały"), + ("", "Export", "Eksportuj"), + ("renderQueue.add", "Add to Render Queue", "Dodaj do kolejki renderowania"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("app.find", "Find", "Znajdź"), + ("layer.addItem", "Add Footage to Comp", "Dodaj materiał do kompozycji"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nowa kompozycja z zaznaczenia..."), + ("", "Dependencies", "Zależności"), + ("file.collectFiles", "Collect Files...", "Zbierz pliki..."), + ("file.consolidateFootage", "Consolidate All Footage", "Scal wszystkie materiały"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Usuń nieużywane materiały"), + ("file.reduceProject", "Reduce Project", "Zredukuj projekt"), + ("file.findMissing", "Find Missing Effects", "Znajdź brakujące efekty"), + ("file.findMissing", "Find Missing Fonts", "Znajdź brakujące czcionki"), + ("file.findMissing", "Find Missing Footage", "Znajdź brakujące materiały"), + ("file.relinkFootage", "Relink Missing Footage...", "Połącz ponownie brakujące materiały..."), + ("file.watchFolder", "Watch Folder...", "Folder obserwowany..."), + ("", "Scripts", "Skrypty"), + ("file.installScript", "Install Script File...", "Zainstaluj plik skryptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Zainstaluj panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Uruchom plik skryptu..."), + ("", "Create Proxy", "Utwórz proxy"), + ("file.createProxy", "Still...", "Klatka..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Ustaw proxy"), + ("file.setProxy", "File...", "Plik..."), + ("file.setProxyNone", "None", "Brak"), + ("", "Interpret Footage", "Interpretuj materiał"), + ("file.interpretFootage", "Main...", "Główny..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamiętaj interpretację"), + ("file.applyInterpretation", "Apply Interpretation", "Zastosuj interpretację"), + ("", "Replace Footage", "Zastąp materiał"), + ("file.replaceFootage", "File...", "Plik..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.replaceWithSolid", "Solid...", "Plansza..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Z warstwową kompozycją"), + ("file.reloadFootage", "Reload Footage", "Wczytaj ponownie materiał"), + ("file.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("file.projectSettings", "Project Settings...", "Ustawienia projektu..."), + ("app.quit", "Exit", "Zakończ"), + ("", "Edit", "Edycja"), + ("edit.undo", "Undo", "Cofnij"), + ("edit.redo", "Redo", "Ponów"), + ("", "History", "Historia"), + ("app.commandPalette", "Quick Apply...", "Szybkie stosowanie..."), + ("edit.cut", "Cut", "Wytnij"), + ("edit.copy", "Copy", "Kopiuj"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopiuj z łączami właściwości"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopiuj ze względnymi łączami właściwości"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopiuj tylko wyrażenie"), + ("edit.paste", "Paste", "Wklej"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Wklej odwrócone klatki kluczowe"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Wklej tekst i dopasuj formatowanie"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Wklej tylko formatowanie tekstu"), + ("edit.clear", "Clear", "Wyczyść"), + ("edit.duplicate", "Duplicate", "Powiel"), + ("edit.splitLayer", "Split Layer", "Podziel warstwę"), + ("edit.liftWorkArea", "Lift Work Area", "Wytnij obszar roboczy"), + ("edit.extractWorkArea", "Extract Work Area", "Wyodrębnij obszar roboczy"), + ("edit.selectAll", "Select All", "Zaznacz wszystko"), + ("edit.deselectAll", "Deselect All", "Usuń zaznaczenie"), + ("", "Label", "Etykieta"), + ("edit.selectLabelGroup", "Select Label Group", "Zaznacz grupę etykiet"), + ("edit.label", "None", "Brak"), + ("edit.label", "Red", "Czerwony"), + ("edit.label", "Yellow", "Żółty"), + ("edit.label", "Aqua", "Akwamaryna"), + ("edit.label", "Pink", "Różowy"), + ("edit.label", "Lavender", "Lawendowy"), + ("edit.label", "Peach", "Brzoskwiniowy"), + ("edit.label", "Sea Foam", "Morska piana"), + ("edit.label", "Blue", "Niebieski"), + ("edit.label", "Green", "Zielony"), + ("edit.label", "Purple", "Fioletowy"), + ("edit.label", "Orange", "Pomarańczowy"), + ("edit.label", "Brown", "Brązowy"), + ("edit.label", "Fuchsia", "Fuksja"), + ("edit.label", "Cyan", "Cyjan"), + ("edit.label", "Sandstone", "Piaskowiec"), + ("edit.label", "Dark Green", "Ciemnozielony"), + ("app.settings", "Edit Label Colors...", "Edytuj kolory etykiet..."), + ("", "Select Keyframe Label Group", "Zaznacz grupę etykiet klatek kluczowych"), + ("keys.selectLabelGroup", "On Selected Layers", "Na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "On All Layers", "Na wszystkich warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Widoczne klatki kluczowe na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Widoczne klatki kluczowe na wszystkich warstwach"), + ("", "Purge", "Wyczyść pamięć"), + ("edit.purge", "All Cache...", "Całą pamięć podręczną..."), + ("edit.purge", "All Memory & Disk Cache...", "Całą pamięć i pamięć dyskową..."), + ("edit.purge", "All Memory", "Całą pamięć"), + ("edit.purge", "All Disk Cache...", "Całą pamięć dyskową..."), + ("edit.purge", "All 3D Cache...", "Całą pamięć 3D..."), + ("edit.purgeUndo", "Undo", "Cofanie"), + ("edit.purge", "Image Cache Memory", "Pamięć podręczna obrazów"), + ("edit.purge", "Snapshot", "Migawka"), + ("edit.editOriginal", "Edit Original...", "Edytuj oryginał..."), + ("", "Templates", "Szablony"), + ("app.templates", "Render Settings...", "Ustawienia renderowania..."), + ("app.templates", "Output Module...", "Moduł wyjściowy..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Skróty klawiaturowe"), + ("", "Preferences", "Preferencje"), + ("", "Composition", "Kompozycja"), + ("comp.new", "New Composition...", "Nowa kompozycja..."), + ("comp.settings", "Composition Settings...", "Ustawienia kompozycji..."), + ("comp.setPosterTime", "Set Poster Time", "Ustaw czas plakatu"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Przytnij kompozycję do obszaru roboczego"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Kadruj kompozycję do obszaru zainteresowania"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Kadruj kompozycję do granic zaznaczonych warstw (warstwy)"), + ("render.addOutputModule", "Add Output Module", "Dodaj moduł wyjściowy"), + ("", "Preview", "Podgląd"), + ("playback.toggle", "Play Current Preview", "Odtwórz bieżący podgląd"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Zapisz klatkę jako"), + ("comp.saveFrameAs", "File...", "Plik..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Warstwy Photoshopa..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopiuj klatkę do schowka"), + ("render.preRender", "Pre-render...", "Wstępne renderowanie..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Zapisz bieżący podgląd..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otwórz w Essential Graphics"), + ("", "Responsive Design — Time", "Projekt responsywny — czas"), + ("comp.responsiveTime", "Create Intro", "Utwórz intro"), + ("comp.responsiveTime", "Create Outro", "Utwórz outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Utwórz chroniony obszar z obszaru roboczego"), + ("comp.flowchart", "Composition Flowchart", "Schemat kompozycji"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Utwórz środowisko VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Wyodrębnij cubemapę..."), + ("", "Layer", "Warstwa"), + ("layer.newText", "Text", "Tekst"), + ("layer.newSolid", "Solid...", "Plansza..."), + ("layer.newLight", "Light...", "Światło..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Obiekt zerowy"), + ("layer.newShape", "Shape Layer", "Warstwa kształtu"), + ("layer.newAdjustment", "Adjustment Layer", "Warstwa korekcyjna"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Warstwa wypełnienia z uwzględnieniem zawartości..."), + ("layer.new3dPrimitive", "Cube", "Sześcian"), + ("layer.new3dPrimitive", "Sphere", "Kula"), + ("layer.new3dPrimitive", "Plane", "Płaszczyzna"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Stożek"), + ("layer.new3dPrimitive", "Cylinder", "Walec"), + ("layer.settings", "Layer Settings...", "Ustawienia warstwy..."), + ("layer.openLayer", "Open Layer", "Otwórz warstwę"), + ("layer.openSource", "Open Layer Source", "Otwórz źródło warstwy"), + ("layer.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nowa maska"), + ("layer.mask.shape", "Mask Shape...", "Kształt maski..."), + ("layer.mask.set", "Mask Feather...", "Wtapianie maski..."), + ("layer.mask.set", "Mask Opacity...", "Krycie maski..."), + ("layer.mask.set", "Mask Expansion...", "Rozszerzenie maski..."), + ("layer.mask.reset", "Reset Mask", "Resetuj maskę"), + ("layer.mask.remove", "Remove Mask", "Usuń maskę"), + ("layer.mask.removeAll", "Remove All Masks", "Usuń wszystkie maski"), + ("track.mask", "Track Mask", "Śledź maskę"), + ("", "Mode", "Tryb"), + ("layer.mask.mode", "None", "Brak"), + ("layer.mask.mode", "Add", "Dodaj"), + ("layer.mask.mode", "Subtract", "Odejmij"), + ("layer.mask.mode", "Intersect", "Część wspólna"), + ("layer.mask.mode", "Lighten", "Rozjaśnij"), + ("layer.mask.mode", "Darken", "Przyciemnij"), + ("layer.mask.mode", "Difference", "Różnica"), + ("layer.mask.invert", "Inverted", "Odwrócona"), + ("layer.mask.lock", "Locked", "Zablokowana"), + ("", "Motion Blur", "Rozmycie ruchu"), + ("layer.mask.motionBlur", "Same As Layer", "Jak warstwa"), + ("layer.mask.motionBlur", "On", "Wł."), + ("layer.mask.motionBlur", "Off", "Wył."), + ("", "Feather Falloff", "Zanikanie wtapiania"), + ("layer.mask.featherFalloff", "Smooth", "Gładkie"), + ("layer.mask.featherFalloff", "Linear", "Liniowe"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odblokuj wszystkie maski"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zablokuj pozostałe maski"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"), + ("", "Mask and Shape Path", "Ścieżka maski i kształtu"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Zamknięta"), + ("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"), + ("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"), + ("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"), + ("", "Quality", "Jakość"), + ("layer.quality", "Best", "Najlepsza"), + ("layer.quality", "Draft", "Robocza"), + ("layer.quality", "Wireframe", "Szkielet"), + ("layer.sampling", "Bilinear", "Dwuliniowa"), + ("layer.sampling", "Bicubic", "Dwusześcienna"), + ("", "Switches", "Przełączniki"), + ("layer.hideOtherVideo", "Hide Other Video", "Ukryj pozostałe wideo"), + ("layer.showAllVideo", "Show All Video", "Pokaż całe wideo"), + ("layer.unlockAll", "Unlock All Layers", "Odblokuj wszystkie warstwy"), + ("layer.expressions", "Enable Expressions", "Włącz wyrażenia"), + ("layer.expressions", "Disable Expressions", "Wyłącz wyrażenia"), + ("layer.setSwitch", "Shy", "Nieśmiała"), + ("layer.setSwitch", "Lock", "Zablokuj"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Wideo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Zwiń"), + ("layer.setSwitch", "Motion Blur", "Rozmycie ruchu"), + ("layer.setSwitch", "Adjustment Layer", "Warstwa korekcyjna"), + ("", "Transform", "Przekształć"), + ("layer.transform", "Reset", "Resetuj"), + ("layer.setTransform", "Anchor Point...", "Punkt zakotwiczenia..."), + ("layer.setTransform", "Position...", "Położenie..."), + ("layer.setTransform", "Scale...", "Skala..."), + ("layer.setTransform", "Orientation...", "Orientacja..."), + ("layer.setTransform", "Rotation...", "Obrót..."), + ("layer.setTransform", "Opacity...", "Krycie..."), + ("layer.transform", "Flip Horizontal", "Odbij w poziomie"), + ("layer.transform", "Flip Vertical", "Odbij w pionie"), + ("layer.transform", "Center In View", "Wyśrodkuj w widoku"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Wyśrodkuj punkt zakotwiczenia w zawartości warstwy"), + ("layer.transform", "Fit to Comp", "Dopasuj do kompozycji"), + ("layer.transform", "Fit to Comp Width", "Dopasuj do szerokości kompozycji"), + ("layer.transform", "Fit to Comp Height", "Dopasuj do wysokości kompozycji"), + ("layer.autoOrient", "Auto-Orient...", "Automatyczna orientacja..."), + ("", "Time", "Czas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Włącz przemapowanie czasu"), + ("layer.timeReverse", "Time-Reverse Layer", "Odwróć czas warstwy"), + ("layer.timeStretch", "Time Stretch...", "Rozciągnięcie czasu..."), + ("layer.freezeFrame", "Freeze Frame", "Zamrożenie klatki"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zamroź na ostatniej klatce"), + ("layer.alignVideoToData", "Align Video to Data", "Wyrównaj wideo do danych"), + ("", "Frame Blending", "Mieszanie klatek"), + ("layer.frameBlending", "Off", "Wył."), + ("layer.frameBlending", "Frame Mix", "Miks klatek"), + ("layer.frameBlending", "Pixel Motion", "Ruch pikseli"), + ("layer.setSwitch", "3D Layer", "Warstwa 3D"), + ("layer.setSwitch", "Guide Layer", "Warstwa pomocnicza"), + ("layer.environment", "Environment Layer", "Warstwa środowiska"), + ("", "Markers", "Znaczniki"), + ("layer.addMarker", "Add Marker", "Dodaj znacznik"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizuj znaczniki ze źródła"), + ("layer.markersLock", "Lock Markers", "Zablokuj znaczniki"), + ("layer.deleteAllMarkers", "Delete All Markers", "Usuń wszystkie znaczniki"), + ("layer.setSwitch", "Preserve Transparency", "Zachowaj przezroczystość"), + ("", "Blending Mode", "Tryb mieszania"), + ("layer.setBlendMode", "Normal", "Normalny"), + ("layer.setBlendMode", "Dissolve", "Rozpuszczenie"), + ("layer.setBlendMode", "Dancing Dissolve", "Tańczące rozpuszczenie"), + ("layer.setBlendMode", "Darken", "Ciemniej"), + ("layer.setBlendMode", "Multiply", "Mnożenie"), + ("layer.setBlendMode", "Color Burn", "Ściemnianie koloru"), + ("layer.setBlendMode", "Classic Color Burn", "Klasyczne ściemnianie koloru"), + ("layer.setBlendMode", "Linear Burn", "Ściemnianie liniowe"), + ("layer.setBlendMode", "Darker Color", "Ciemniejszy kolor"), + ("layer.setBlendMode", "Add", "Dodaj"), + ("layer.setBlendMode", "Lighten", "Jaśniej"), + ("layer.setBlendMode", "Screen", "Mnożenie odwrotne"), + ("layer.setBlendMode", "Color Dodge", "Rozjaśnianie koloru"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasyczne rozjaśnianie koloru"), + ("layer.setBlendMode", "Linear Dodge", "Rozjaśnianie liniowe"), + ("layer.setBlendMode", "Lighter Color", "Jaśniejszy kolor"), + ("layer.setBlendMode", "Overlay", "Nakładka"), + ("layer.setBlendMode", "Soft Light", "Łagodne światło"), + ("layer.setBlendMode", "Hard Light", "Ostre światło"), + ("layer.setBlendMode", "Linear Light", "Światło liniowe"), + ("layer.setBlendMode", "Vivid Light", "Jaskrawe światło"), + ("layer.setBlendMode", "Pin Light", "Światło punktowe"), + ("layer.setBlendMode", "Hard Mix", "Twarde mieszanie"), + ("layer.setBlendMode", "Difference", "Różnica"), + ("layer.setBlendMode", "Classic Difference", "Klasyczna różnica"), + ("layer.setBlendMode", "Exclusion", "Wykluczenie"), + ("layer.setBlendMode", "Subtract", "Odejmowanie"), + ("layer.setBlendMode", "Divide", "Dzielenie"), + ("layer.setBlendMode", "Hue", "Barwa"), + ("layer.setBlendMode", "Saturation", "Nasycenie"), + ("layer.setBlendMode", "Color", "Kolor"), + ("layer.setBlendMode", "Luminosity", "Jasność"), + ("layer.setBlendMode", "Stencil Alpha", "Szablon alfa"), + ("layer.setBlendMode", "Stencil Luma", "Szablon luma"), + ("layer.setBlendMode", "Silhouette Alpha", "Sylweta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Sylweta luma"), + ("layer.setBlendMode", "Alpha Add", "Dodawanie alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescencja premul"), + ("layer.setBlendMode", "Next Blending Mode", "Następny tryb mieszania"), + ("layer.setBlendMode", "Previous Blending Mode", "Poprzedni tryb mieszania"), + ("", "Track Matte", "Matowa ścieżka"), + ("layer.trackMatte", "No Track Matte", "Bez matowej ścieżki"), + ("layer.trackMatte", "Alpha Matte", "Matowa alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Odwrócona matowa alfa"), + ("layer.trackMatte", "Luma Matte", "Matowa luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Odwrócona matowa luma"), + ("layer.trackMatte", "Matte with Layer Above", "Matowa z warstwą powyżej"), + ("layer.trackMatte", "Matte with Layer Below", "Matowa z warstwą poniżej"), + ("", "Layer Styles", "Style warstwy"), + ("layer.style.options", "Layer Style Options...", "Opcje stylu warstwy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Konwertuj na edytowalne style"), + ("layer.style.showAll", "Show All", "Pokaż wszystkie"), + ("layer.style.removeAll", "Remove All", "Usuń wszystkie"), + ("layer.style.dropShadow", "Drop Shadow", "Cień"), + ("layer.style.innerShadow", "Inner Shadow", "Cień wewnętrzny"), + ("layer.style.outerGlow", "Outer Glow", "Blask zewnętrzny"), + ("layer.style.innerGlow", "Inner Glow", "Blask wewnętrzny"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Faza i płaskorzeźba"), + ("layer.style.satin", "Satin", "Satyna"), + ("layer.style.colorOverlay", "Color Overlay", "Nakładka koloru"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Nakładka gradientu"), + ("layer.style.stroke", "Stroke", "Obrys"), + ("path.groupShapes", "Group Shapes", "Grupuj kształty"), + ("path.ungroupShapes", "Ungroup Shapes", "Rozgrupuj kształty"), + ("", "Arrange", "Kolejność"), + ("layer.arrange", "Bring Layer to Front", "Przesuń warstwę na wierzch"), + ("layer.arrange", "Bring Layer Forward", "Przesuń warstwę do przodu"), + ("layer.arrange", "Send Layer Backward", "Przesuń warstwę do tyłu"), + ("layer.arrange", "Send Layer to Back", "Przesuń warstwę na spód"), + ("", "Reveal", "Pokaż"), + ("layer.revealSource", "Reveal Layer Source in Project", "Pokaż źródło warstwy w projekcie"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Pokaż warstwę w schemacie projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Pokaż kompozycję w projekcie"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Pokaż błędy wyrażeń"), + ("", "Create", "Utwórz"), + ("layer.create", "Convert to Editable Text", "Konwertuj na edytowalny tekst"), + ("layer.create", "Create Shapes from Text", "Utwórz kształty z tekstu"), + ("layer.create", "Create Masks from Text", "Utwórz maski z tekstu"), + ("layer.create", "Create Shapes from Vector Layer", "Utwórz kształty z warstwy wektorowej"), + ("layer.create", "Create Keyframes from Data", "Utwórz klatki kluczowe z danych"), + ("layer.create", "Null Controllers for Positional Points", "Kontrolery zerowe dla punktów położenia"), + ("layer.create", "Null Controllers for Path Points", "Kontrolery zerowe dla punktów ścieżki"), + ("layer.create", "Nulls Following Path Points", "Obiekty zerowe podążające za punktami ścieżki"), + ("layer.create", "Null Tracing Along Path", "Obiekt zerowy wzdłuż ścieżki"), + ("layer.create", "Create 3D Layer Instance", "Utwórz instancję warstwy 3D"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Utwórz kamerę z widoku 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Utwórz zestaw stereo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Utwórz obiekt zerowy orbity"), + ("camera.fromModel", "Create Cameras from 3D Model", "Utwórz kamery z modelu 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Powiąż odległość ostrości z punktem zainteresowania"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Powiąż odległość ostrości z warstwą"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Ustaw odległość ostrości na warstwę"), + ("layer.cameraSettings", "Camera Settings...", "Ustawienia kamery..."), + ("view.reset3DView", "Reset 3D View", "Resetuj widok 3D"), + ("", "Light", "Światło"), + ("light.fromModel", "Create Lights from 3D Model", "Utwórz światła z modelu 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Steruj światłem za pomocą kamery"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Utwórz warstwę tła światła otoczenia"), + ("", "Material", "Materiał"), + ("material.revealSource", "Reveal Material Source in Project", "Pokaż źródło materiału w projekcie"), + ("material.reset", "Reset Material", "Resetuj materiał"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Powiel i przypisz materiał"), + ("layer.autoTrace", "Auto-trace...", "Automatyczne obrysowanie..."), + ("layer.precompose", "Pre-compose...", "Utwórz kompozycję wstępną..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Wykrywanie zmian scen..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ustawienia efektów"), + ("effect.applyLast", "Last Effect", "Ostatni efekt"), + ("effect.removeAll", "Remove All", "Usuń wszystkie"), + ("effect.manage", "Manage Effects...", "Zarządzaj efektami..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Wczytaj wtyczkę efektu..."), + ("", "Animation", "Animacja"), + ("anim.savePreset", "Save Animation Preset...", "Zapisz ustawienie animacji..."), + ("anim.applyPreset", "Apply Animation Preset...", "Zastosuj ustawienie animacji..."), + ("", "Recent Animation Presets", "Ostatnie ustawienia animacji"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Wyczyść ostatnie ustawienia"), + ("anim.browsePresets", "Browse Presets...", "Przeglądaj ustawienia..."), + ("", "Text Animation Presets", "Ustawienia animacji tekstu"), + ("layer.applyTextPreset", "Typewriter", "Maszyna do pisania"), + ("layer.applyTextPreset", "Fade Up Characters", "Pojawianie się znaków"), + ("layer.applyTextPreset", "Bounce In Words", "Odbijające się słowa"), + ("layer.applyTextPreset", "Tracking In", "Zbliżanie liter"), + ("layer.applyTextPreset", "Scramble", "Mieszanie znaków"), + ("layer.applyTextPreset", "Blur In", "Wyostrzanie z rozmycia"), + ("layer.applyTextPreset", "Jitter", "Drganie"), + ("layer.applyTextPreset", "Drop In Lines", "Wpadające wiersze"), + ("anim.addKeyframe", "Add Keyframe", "Dodaj klatkę kluczową"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Przełącz klatkę zatrzymania"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolacja klatek kluczowych..."), + ("keys.velocity", "Keyframe Velocity...", "Prędkość klatek kluczowych..."), + ("", "Keyframe Assistant", "Asystent klatek kluczowych"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Konwertuj audio na klatki kluczowe"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Konwertuj wyrażenie na klatki kluczowe"), + ("keys.easyEase", "Easy Ease", "Łagodne przejście"), + ("keys.easyEaseIn", "Easy Ease In", "Łagodne wejście"), + ("keys.easyEaseOut", "Easy Ease Out", "Łagodne wyjście"), + ("keys.exponentialScale", "Exponential Scale", "Skalowanie wykładnicze"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Ułóż warstwy w sekwencję..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Odwróć czas klatek kluczowych"), + ("", "Animate Text", "Animuj tekst"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("layer.addTextAnimator", "Anchor Point", "Punkt zakotwiczenia"), + ("layer.addTextAnimator", "Position", "Położenie"), + ("layer.addTextAnimator", "Scale", "Skala"), + ("layer.addTextAnimator", "Skew", "Pochylenie"), + ("layer.addTextAnimator", "Rotation", "Obrót"), + ("layer.addTextAnimator", "Opacity", "Krycie"), + ("layer.addTextAnimator", "All Transform Properties", "Wszystkie właściwości przekształcenia"), + ("", "Fill Color", "Kolor wypełnienia"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Barwa"), + ("layer.addTextAnimator", "Saturation", "Nasycenie"), + ("layer.addTextAnimator", "Brightness", "Jasność"), + ("", "Stroke Color", "Kolor obrysu"), + ("layer.addTextAnimator", "Stroke Width", "Szerokość obrysu"), + ("layer.addTextAnimator", "Tracking", "Światło"), + ("layer.addTextAnimator", "Line Anchor", "Zakotwiczenie wiersza"), + ("layer.addTextAnimator", "Line Spacing", "Odstęp między wierszami"), + ("layer.addTextAnimator", "Character Offset", "Przesunięcie znaku"), + ("layer.addTextAnimator", "Character Value", "Wartość znaku"), + ("layer.addTextAnimator", "Blur", "Rozmycie"), + ("text.animatorFontAxes", "Variable Font Axes", "Osie czcionki zmiennej"), + ("", "Add Text Selector", "Dodaj selektor tekstu"), + ("text.addSelector", "Range", "Zakres"), + ("text.addSelector", "Wiggly", "Falowanie"), + ("text.addSelector", "Expression", "Wyrażenie"), + ("text.removeAllAnimators", "Remove All Text Animators", "Usuń wszystkie animatory tekstu"), + ("prop.setExpression", "Add Expression", "Dodaj wyrażenie"), + ("essential.addProperty", "Add Property to Essential Graphics", "Dodaj właściwość do Essential Graphics"), + ("prop.separateDimensions", "Separate Dimensions", "Rozdziel wymiary"), + ("track.camera", "Track Camera", "Śledź kamerę"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "Śledź ruch"), + ("track.stabilize", "Stabilize Motion", "Stabilizuj ruch"), + ("track.property", "Track this Property", "Śledź tę właściwość"), + ("anim.reveal", "Reveal Properties with Keyframes", "Pokaż właściwości z klatkami kluczowymi"), + ("anim.reveal", "Reveal Properties with Animation", "Pokaż właściwości z animacją"), + ("anim.reveal", "Reveal All Modified Properties", "Pokaż wszystkie zmienione właściwości"), + ("", "View", "Widok"), + ("view.newViewer", "New Viewer", "Nowa przeglądarka"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Podziel z nową zablokowaną przeglądarką"), + ("view.zoomIn", "Zoom In", "Powiększ"), + ("view.zoomOut", "Zoom Out", "Pomniejsz"), + ("", "Resolution", "Rozdzielczość"), + ("view.res.full", "Full", "Pełna"), + ("view.res.half", "Half", "Połowa"), + ("view.res.third", "Third", "Jedna trzecia"), + ("view.res.quarter", "Quarter", "Jedna czwarta"), + ("view.res.custom", "Custom...", "Niestandardowa..."), + ("view.displayColorManagement", "Use Display Color Management", "Używaj zarządzania kolorem wyświetlacza"), + ("", "Simulate Output", "Symuluj wyjście"), + ("view.simulateOutput", "No Output Simulation", "Bez symulacji wyjścia"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starsze RGB komputerów Macintosh (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Standardowe internetowe RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Liniowe (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Moje niestandardowe RGB..."), + ("view.simulateOutput", "Custom...", "Niestandardowe..."), + ("view.rulers", "Show Rulers", "Pokaż linijki"), + ("", "Panel Background Color", "Kolor tła panelu"), + ("view.panelBackground", "Black", "Czarny"), + ("view.panelBackground", "Dark Gray", "Ciemnoszary"), + ("view.panelBackground", "Medium Gray (Default)", "Średnioszary (domyślny)"), + ("view.panelBackground", "Light Gray", "Jasnoszary"), + ("view.panelBackground", "White", "Biały"), + ("view.panelBackground", "Custom", "Niestandardowy"), + ("view.panelBackground", "Select Custom Background Color...", "Wybierz niestandardowy kolor tła..."), + ("view.guides", "Show Guides", "Pokaż linie pomocnicze"), + ("view.snapToGuides", "Snap to Guides", "Przyciągaj do linii pomocniczych"), + ("view.lockGuides", "Lock Guides", "Zablokuj linie pomocnicze"), + ("view.addGuide", "Add Guide...", "Dodaj linię pomocniczą..."), + ("view.addPercentGuide", "Add Percentage Guide...", "Dodaj procentową linię pomocniczą..."), + ("view.convertGuides", "Convert Guides to Percentage", "Konwertuj linie pomocnicze na procenty"), + ("view.convertGuides", "Convert Guides to Pixels", "Konwertuj linie pomocnicze na piksele"), + ("view.clearGuides", "Clear Guides", "Wyczyść linie pomocnicze"), + ("view.importGuides", "Import Guides...", "Importuj linie pomocnicze..."), + ("view.exportGuides", "Export Guides...", "Eksportuj linie pomocnicze..."), + ("view.grid", "Show Grid", "Pokaż siatkę"), + ("view.snapToGrid", "Snap to Grid", "Przyciągaj do siatki"), + ("view.snapping", "Snapping", "Przyciąganie"), + ("view.options", "View Options...", "Opcje widoku..."), + ("view.layerControls", "Show Layer Controls", "Pokaż elementy sterujące warstwy"), + ("", "Switch View Layout", "Przełącz układ widoków"), + ("view.layout", "1 View", "1 widok"), + ("view.layout", "2 Views", "2 widoki"), + ("view.layout", "4 Views", "4 widoki"), + ("view.shareViewOptions", "Share View Options", "Wspólne opcje widoku"), + ("", "Switch 3D View", "Przełącz widok 3D"), + ("view.3d.activeCamera", "Active Camera", "Aktywna kamera"), + ("view.3d.default", "Default", "Domyślny"), + ("view.3d.front", "Front", "Przód"), + ("view.3d.left", "Left", "Lewo"), + ("view.3d.top", "Top", "Góra"), + ("view.3d.back", "Back", "Tył"), + ("view.3d.right", "Right", "Prawo"), + ("view.3d.bottom", "Bottom", "Dół"), + ("view.3d.custom1", "Custom View 1", "Widok niestandardowy 1"), + ("view.3d.custom2", "Custom View 2", "Widok niestandardowy 2"), + ("view.3d.custom3", "Custom View 3", "Widok niestandardowy 3"), + ("", "Assign Shortcut to 3D View", "Przypisz skrót do widoku 3D"), + ("view.3d.last", "Switch to Last 3D View", "Przełącz na ostatni widok 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Patrz na zaznaczone warstwy"), + ("view.lookAtAll", "Look at All Layers", "Patrz na wszystkie warstwy"), + ("time.set", "Go to Time...", "Przejdź do czasu..."), + ("view.fullScreen", "Enter Full Screen", "Pełny ekran"), + ("", "Window", "Okno"), + ("", "Workspace", "Obszar roboczy"), + ("window.workspace", "Default", "Domyślny"), + ("window.workspace", "Review", "Przegląd"), + ("window.workspace", "Learn", "Nauka"), + ("window.workspace", "Small Screen", "Mały ekran"), + ("window.workspace", "Standard", "Standardowy"), + ("window.workspace", "All Panels", "Wszystkie panele"), + ("window.workspace", "Animation", "Animacja"), + ("window.workspace", "Color", "Kolor"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Essential Graphics"), + ("window.workspace", "Minimal", "Minimalny"), + ("window.workspace", "Motion Tracking", "Śledzenie ruchu"), + ("window.workspace", "Paint", "Malowanie"), + ("window.workspace", "Text", "Tekst"), + ("window.workspace", "Undocked Panels", "Panele odpięte"), + ("window.resetWorkspace", "Reset to Saved Layout", "Przywróć zapisany układ"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Zapisz zmiany w tym obszarze roboczym"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Zapisz jako nowy obszar roboczy..."), + ("window.editWorkspaces", "Edit Workspaces...", "Edytuj obszary robocze..."), + ("", "Assign Shortcut to Workspace", "Przypisz skrót do obszaru roboczego"), + ("window.panel", "Align", "Wyrównanie"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pędzle"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Wypełnienie z uwzględnieniem zawartości"), + ("window.panel", "Effects & Presets", "Efekty i ustawienia"), + ("window.panel", "Essential Graphics", "Essential Graphics"), + ("window.panel", "Info", "Informacje"), + ("help.inAppTutorials", "Learn", "Nauka"), + ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolacja maski"), + ("window.panel", "Media Browser", "Przeglądarka multimediów"), + ("window.panel", "Metadata", "Metadane"), + ("window.panel", "Motion Sketch", "Szkic ruchu"), + ("window.panel", "Paint", "Malowanie"), + ("window.panel", "Paragraph", "Akapit"), + ("window.panel", "Preview", "Podgląd"), + ("window.panel", "Progress", "Postęp"), + ("window.panel", "Properties", "Właściwości"), + ("window.panel", "Script Console", "Konsola skryptów"), + ("window.panel", "Smoother", "Wygładzanie"), + ("window.panel", "Tools", "Narzędzia"), + ("window.panel", "Tracker", "Śledzenie"), + ("window.panel", "Wiggler", "Wahanie"), + ("window.panel", "Composition", "Kompozycja"), + ("window.panel", "Flowchart", "Schemat"), + ("window.panel", "Footage", "Materiał"), + ("window.panel", "Layer", "Warstwa"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Kolejka renderowania"), + ("window.panel", "Timeline", "Oś czasu"), + ("window.panel", "Create Nulls From Paths", "Utwórz obiekty zerowe ze ścieżek"), + ("window.panel", "VR Comp Editor", "Edytor kompozycji VR"), + ("", "Help", "Pomoc"), + ("help.docs", "EffectCraft Help...", "Pomoc EffectCraft..."), + ("help.docs", "Scripting Help...", "Pomoc dotycząca skryptów..."), + ("help.docs", "Expression Reference...", "Dokumentacja wyrażeń..."), + ("help.docs", "Effect Reference...", "Dokumentacja efektów..."), + ("anim.browsePresets", "Animation Presets...", "Ustawienia animacji..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Skróty klawiaturowe..."), + ("help.inAppTutorials", "In-App Tutorials...", "Samouczki w aplikacji..."), + ("help.onlineTutorials", "Online Tutorials...", "Samouczki online..."), + ("help.systemReport", "System Compatibility Report...", "Raport zgodności systemu..."), + ("help.enableLogging", "Enable Logging", "Włącz rejestrowanie"), + ("help.revealLogFile", "Reveal Logging File", "Pokaż plik dziennika"), + ("help.discord", "Join the ArtCraft Discord...", "Dołącz do Discorda ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Prześlij opinię..."), + ("help.website", "ArtCraft Website", "Witryna ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Strona główna EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("file.openDemoProject", "Open Demo Project", "Otwórz projekt demonstracyjny"), + ("", "Recent Script Files", "Ostatnie pliki skryptów"), + ("layer.newPrimitive", "3D Primitive...", "Bryła podstawowa 3D..."), +]; + /// Czech (cs): original phrase translations of every fixed menu entry. const CZECH: &[(&str, &str, &str)] = &[ ("", "Front", "Přední strana"), @@ -6548,6 +7245,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), + ("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), @@ -7179,6 +7878,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"), + ("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"), ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), @@ -7499,6 +8200,19 @@ mod tests { println!("total: {total}"); } + /// Prints the translated-row counts per language, the numbers `docs/localization-parity.md` + /// quotes. `cargo xtask i18n-report` runs it with `--nocapture`. + #[test] + fn catalog_row_counts_report() { + println!("Localization catalog rows:"); + for code in CATALOG_CODES { + let menus = menu_catalog(code).map_or(0, |catalog| catalog.len()); + let panels = ui::translated_rows(code); + let effect_names = effects::translated_rows(code); + println!("{code}: menus {menus}, panels {panels}, effect names {effect_names}, total {}", menus + panels + effect_names); + } + } + #[test] fn menu_catalogs_have_no_stale_or_duplicate_rows() { let mut actual = BTreeSet::new(); @@ -7537,6 +8251,43 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + /// Match System in the browser also picks the Russian, French and German catalogs, in the + /// order of the browser's preferences (#1700). + #[test] + fn browser_preferences_choose_russian_french_and_german() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + for (locales, expected) in + [(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")] + { + assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}"); + } + } + + /// The Edit menu's Undo and Redo name the history's action in the interface language, also in + /// the languages added with #1700. + #[test] + fn undo_and_redo_are_localized_for_russian_french_and_german() { + for (language, undo, no_undo, redo, no_redo) in [ + ("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"), + ("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"), + ("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"), + ] { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + let entry_of = |app: &EffectcraftApp, command: &str| { + let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap(); + entry(app, e, e.label.clone()) + }; + assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}"); + assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}"); + app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap(); + let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap(); + assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}"); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}"); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -7566,12 +8317,26 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("pl"), "pl"), + (Some("pl-PL"), "pl"), + (Some("pl_PL.UTF-8"), "pl"), + (Some("PL_pl.UTF-8"), "pl"), (Some("ka"), "ka"), (Some("ka-GE"), "ka"), (Some("ka_GE.UTF-8"), "ka"), + (Some("ru"), "ru"), + (Some("ru-RU"), "ru"), + (Some("ru-SK"), "ru"), + (Some("ru_RU.UTF-8"), "ru"), + (Some("de"), "de"), + (Some("de-DE"), "de"), + (Some("de_AT.UTF-8"), "de"), + (Some("fr"), "fr"), + (Some("fr-FR"), "fr"), + (Some("fr_CA.UTF-8"), "fr"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), - (Some("de-DE"), "en"), + (Some("nl-NL"), "en"), (None, "en"), ] { assert_eq!(supported(locale), language, "{locale:?}"); @@ -7583,6 +8348,7 @@ mod tests { assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); assert_eq!(czech(&app), system_language() == "cs"); + assert_eq!(polish(&app), system_language() == "pl"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -7609,6 +8375,7 @@ mod tests { ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH), + ("pl", POLISH), ("ka", GEORGIAN), ] { for (command, en, translated) in catalog { @@ -7620,6 +8387,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); @@ -7629,6 +8425,35 @@ mod tests { assert_eq!(code("unknown"), "en"); } + /// Every catalog language draws its menu entries from the catalog, including those with no + /// undo/redo wording of their own: choosing Deutsch, Русский or Français left the menus in + /// English (#1449). + #[test] + fn every_catalog_language_translates_menu_entries() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let menu_entry = |command: &str, label: &str| MenuEntry { + label: label.into(), + command: command.into(), + params: serde_json::Value::Null, + shortcut: None, + alt_shortcuts: Vec::new(), + }; + let new_comp = menu_entry("comp.new", "New Composition..."); + let undo = menu_entry("edit.undo", "Undo"); + for code in CATALOG_CODES { + app.session.execute("prefs.set", json!({"key":"general.language", "value":code})).unwrap(); + let drawn = entry(&app, &new_comp, new_comp.label.clone()); + assert_eq!(drawn, label(&app, "comp.new", "New Composition..."), "{code}"); + assert_ne!(drawn, new_comp.label, "{code}: the menu entry is drawn in English"); + } + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich"); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); + assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 162300f9c..bb7e3715a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -87,6 +87,13 @@ pub(crate) fn name(id: &str, english: &str) -> String { catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } +/// The number of effect-name rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, |c| c.rows.len()) +} + /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// @@ -143,6 +150,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "雾 3D"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 图层实例"), ("ec.audio.backwards", "Backwards", "倒放"), ("ec.audio.basstreble", "Bass & Treble", "低音和高音"), ("ec.audio.compressor", "Compressor", "压缩程序"), @@ -451,31 +459,313 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, /// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the -/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`, -/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that -/// English name here rather than being left out. +/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name +/// here rather than being left out. An effect with no row draws its English name anyway, which is +/// what the `OCIO` set, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects do. /// -/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface -/// (its generated Effect menu and its Effects & Presets panel). +/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu +/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to +/// hand. A published glossary is used to cross-check, never as the only source: it normalises the +/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for +/// the `CC` series, which AE 2026 leaves in English. const JAPANESE_EFFECTS: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3Dチャンネル抽出"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "デプスマット"), + ("ec.3d.depthoffield", "Depth of Field", "被写界深度"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "フォグ3D"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "IDマット"), + ("ec.audio.backwards", "Backwards", "リバース"), + ("ec.audio.basstreble", "Bass & Treble", "バス&トレブル"), + ("ec.audio.compressor", "Compressor", "コンプレッサー"), + ("ec.audio.delay", "Delay", "ディレイ"), + ("ec.audio.distortion", "Distortion", "歪み"), + ("ec.audio.flangechorus", "Flange & Chorus", "フランジ&コーラス"), + ("ec.audio.gate", "Gate", "ゲート"), + ("ec.audio.highlowpass", "High-Low Pass", "ハイパス/ローパス"), + ("ec.audio.modulator", "Modulator", "モジュレーター"), + ("ec.audio.parametriceq", "Parametric EQ", "パラメトリックEQ"), + ("ec.audio.reverb", "Reverb", "リバーブ"), + ("ec.audio.stereomixer", "Stereo Mixer", "ステレオミキサー"), + ("ec.audio.tone", "Tone", "トーン"), ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), ("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"), ("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"), ("ec.blur.radial", "Radial Blur", "ブラー(放射状)"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "インターレースのちらつき削減"), + ("ec.blur.sharpen", "Sharpen", "シャープ"), + ("ec.blur.smart", "Smart Blur", "ブラー(詳細)"), + ("ec.blur.unsharp", "Unsharp Mask", "アンシャープマスク"), + ("ec.channel.arithmetic", "Arithmetic", "アリスマチック"), + ("ec.channel.blend", "Blend", "ブレンド"), + ("ec.channel.calculations", "Calculations", "計算"), + ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.combiner", "Channel Combiner", "チャンネルコンバイナー"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "合成アリスマチック"), + ("ec.channel.invert", "Invert", "反転"), + ("ec.channel.minimax", "Minimax", "最大/最小"), + ("ec.channel.removecolormatting", "Remove Color Matting", "カラーマット削除"), + ("ec.channel.setchannels", "Set Channels", "チャンネル設定"), + ("ec.channel.setmatte", "Set Matte", "マット設定"), + ("ec.channel.shiftchannels", "Shift Channels", "チャンネルシフト"), + ("ec.channel.solidcomposite", "Solid Composite", "単色合成"), + ("ec.color.autocolor", "Auto Color", "自動カラー補正"), + ("ec.color.autocontrast", "Auto Contrast", "自動コントラスト"), + ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), + ("ec.color.blackwhite", "Black & White", "白黒"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "色を変更"), + ("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"), + ("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"), + ("ec.color.colorama", "Colorama", "コロラマ"), + ("ec.color.colorbalance", "Color Balance", "カラーバランス"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "カラーバランス(HLS)"), + ("ec.color.colorlink", "Color Link", "カラーリンク"), + ("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"), + ("ec.color.curves", "Curves", "トーンカーブ"), + ("ec.color.equalize", "Equalize", "平均化(イコライズ)"), + ("ec.color.exposure", "Exposure", "露光量"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "ガンマ/ペデスタル/ゲイン"), + ("ec.color.huesaturation", "Hue/Saturation", "色相/彩度"), + ("ec.color.leavecolor", "Leave Color", "色抜き"), + ("ec.color.levels", "Levels", "レベル補正"), + ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL 変換"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO 色空間変換"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO 表示変換"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO ファイル変換"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO Look トランスフォーム"), + ("ec.color.photofilter", "Photo Filter", "レンズフィルター"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), + ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"), + ("ec.color.tint", "Tint", "色かぶり補正"), + ("ec.color.tritone", "Tritone", "トライトーン"), + ("ec.color.vibrance", "Vibrance", "自然な彩度"), + ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), + ("ec.control.angle", "Angle Control", "角度制御"), + ("ec.control.checkbox", "Checkbox Control", "チェックボックス制御"), + ("ec.control.color", "Color Control", "カラー制御"), + ("ec.control.dropdown", "Dropdown Menu Control", "ドロップダウンメニュー制御"), + ("ec.control.layer", "Layer Control", "レイヤー制御"), + ("ec.control.point", "Point Control", "ポイント制御"), + ("ec.control.point3d", "3D Point Control", "3D ポイント制御"), + ("ec.control.slider", "Slider Control", "スライダー制御"), + ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), + ("ec.distort.bulge", "Bulge", "バルジ"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "コーナーピン"), + ("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"), + ("ec.distort.liquify", "Liquify", "ゆがみ"), + ("ec.distort.magnify", "Magnify", "ズーム"), + ("ec.distort.meshwarp", "Mesh Warp", "メッシュワープ"), + ("ec.distort.mirror", "Mirror", "ミラー"), + ("ec.distort.offset", "Offset", "オフセット"), + ("ec.distort.opticscompensation", "Optics Compensation", "レンズ補正"), + ("ec.distort.polar", "Polar Coordinates", "極座標"), + ("ec.distort.reshape", "Reshape", "リシェープ"), + ("ec.distort.ripple", "Ripple", "波紋"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "ローリングシャッターの修復"), + ("ec.distort.smear", "Smear", "にじみ"), + ("ec.distort.spherize", "Spherize", "球面"), + ("ec.distort.transform", "Transform", "トランスフォーム"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "タービュレントディスプレイス"), + ("ec.distort.twirl", "Twirl", "回転"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "Twirl (Legacy)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(詳細を保持)"), + ("ec.distort.warp", "Warp", "ワープ"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"), + ("ec.distort.wavewarp", "Wave Warp", "波形ワープ"), + ("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"), + ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), + ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), + ("ec.generate.beam", "Beam", "レーザー"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "セルパターン"), + ("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"), + ("ec.generate.circle", "Circle", "円"), + ("ec.generate.ellipse", "Ellipse", "楕円"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "スポイト塗り"), + ("ec.generate.fill", "Fill", "塗り"), + ("ec.generate.fourcolor", "4-Color Gradient", "4色グラデーション"), + ("ec.generate.fractal", "Fractal", "フラクタル"), + ("ec.generate.gradientramp", "Gradient Ramp", "グラデーション"), + ("ec.generate.grid", "Grid", "グリッド"), + ("ec.generate.lensflare", "Lens Flare", "レンズフレア"), + ("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"), + ("ec.generate.radiowaves", "Radio Waves", "電波"), + ("ec.generate.scribble", "Scribble", "落書き"), + ("ec.generate.stroke", "Stroke", "線"), + ("ec.generate.vegas", "Vegas", "ベガス"), + ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.colordifference", "Color Difference Key", "カラー差キー"), ("ec.key.colorkey", "Color Key", "カラーキー"), + ("ec.key.colorrange", "Color Range", "色範囲"), + ("ec.key.differencematte", "Difference Matte", "異なるマット"), + ("ec.key.extract", "Extract", "抽出"), + ("ec.key.innerouter", "Inner/Outer Key", "インナー/アウターキー"), + ("ec.key.keycleaner", "Key Cleaner", "Key Cleaner"), + ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), - ("ec.obsolete.basic3d", "Basic 3D", "基本 3D"), + ("ec.keying.keylight", "Key Light", "Keylight (1.2)"), + ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), + ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), + ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), + ("ec.matte.simplechoker", "Simple Choker", "チョーク"), + ("ec.noise.addgrain", "Add Grain", "グレイン(追加)"), + ("ec.noise.curlnoise", "Curl Noise", "カールノイズ"), + ("ec.noise.dustscratches", "Dust & Scratches", "ダスト&スクラッチ"), + ("ec.noise.fractal", "Fractal Noise", "フラクタルノイズ"), + ("ec.noise.matchgrain", "Match Grain", "グレイン(マッチ)"), + ("ec.noise.median", "Median", "ミディアン"), + ("ec.noise.medianlegacy", "Median (Legacy)", "ミディアン(レガシー)"), + ("ec.noise.noise", "Noise", "ノイズ"), + ("ec.noise.noisealpha", "Noise Alpha", "ノイズアルファ"), + ("ec.noise.noisehls", "Noise HLS", "ノイズHLS"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "ノイズHLSオート"), + ("ec.noise.removegrain", "Remove Grain", "グレイン(除去)"), + ("ec.noise.turbulent", "Turbulent Noise", "タービュレントノイズ"), + ("ec.obsolete.basic3d", "Basic 3D", "基本3D"), ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), + ("ec.obsolete.mochashape", "Mocha shape", "mocha shape"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), + ("ec.perspective.3dglasses", "3D Glasses", "3Dメガネ"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "ベベルアルファ"), + ("ec.perspective.beveledges", "Bevel Edges", "ベベルエッジ"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D カメラトラッカー"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), + ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), + ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.dropshadow", "Drop Shadow", "ドロップシャドウ"), + ("ec.perspective.radialshadow", "Radial Shadow", "放射状シャドウ"), + ("ec.sim.carddance", "Card Dance", "カードダンス"), + ("ec.sim.caustics", "Caustics", "コースティック"), + ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), + ("ec.sim.cchair", "CC Hair", "CC Hair"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.foam", "Foam", "泡"), + ("ec.sim.particleplayground", "Particle Playground", "パーティクルプレイグラウンド"), + ("ec.sim.shatter", "Shatter", "シャター"), + ("ec.sim.waveworld", "Wave World", "ウェーブワールド"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"), + ("ec.stylize.cartoon", "Cartoon", "カートゥーン"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"), + ("ec.stylize.emboss", "Emboss", "エンボス"), + ("ec.stylize.findedges", "Find Edges", "輪郭検出"), + ("ec.stylize.glow", "Glow", "グロー"), + ("ec.stylize.mosaic", "Mosaic", "モザイク"), + ("ec.stylize.motiontile", "Motion Tile", "モーションタイル"), + ("ec.stylize.posterize", "Posterize", "ポスタリゼーション"), + ("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"), + ("ec.stylize.scatter", "Scatter", "拡散"), + ("ec.stylize.strobe", "Strobe Light", "ストロボ"), + ("ec.stylize.texturize", "Texturize", "テクスチャ"), + ("ec.stylize.threshold", "Threshold", "しきい値"), + ("ec.text.numbers", "Numbers", "番号"), + ("ec.text.timecode", "Timecode", "タイムコード"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), + ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.echo", "Echo", "エコー"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), + ("ec.time.posterizetime", "Posterize Time", "ポスタリゼーション時間"), + ("ec.time.timedifference", "Time Difference", "時間差"), + ("ec.time.timedisplacement", "Time Displacement", "時間置き換え"), + ("ec.time.timewarp", "Timewarp", "タイムワープ"), + ("ec.transition.blockdissolve", "Block Dissolve", "ブロックディゾルブ"), + ("ec.transition.cardwipe", "Card Wipe", "カードワイプ"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), + ("ec.transition.cctwister", "CC Twister", "CC Twister"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.gradientwipe", "Gradient Wipe", "グラデーションワイプ"), + ("ec.transition.iriswipe", "Iris Wipe", "アイリスワイプ"), + ("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"), + ("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"), + ("ec.transition.venetian", "Venetian Blinds", "ブラインド"), + ("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"), + ("ec.utility.growbounds", "Grow Bounds", "範囲拡張"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR コンパンダー"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ハイライト圧縮"), + ("ec.vr.blur", "VR Blur", "VR ブラー"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR 色収差"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR カラーグラデーション"), + ("ec.vr.converter", "VR Converter", "VR コンバーター"), + ("ec.vr.denoise", "VR De-Noise", "VR ノイズ除去"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR デジタルグリッチ"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR フラクタルノイズ"), + ("ec.vr.glow", "VR Glow", "VR グロー"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR 平面として投影"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR 回転(球)"), + ("ec.vr.sharpen", "VR Sharpen", "VR シャープ"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球体から平面"), ]; /// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. @@ -490,12 +780,13 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), ("Expression Controls", "式制御"), + ("Generate", "描画"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), ("Noise & Grain", "ノイズ&グレイン"), ("Obsolete", "旧バージョン"), - ("Paint", "描画"), + ("Paint", "ペイント"), ("Perspective", "遠近"), ("Simulation", "シミュレーション"), ("Stylize", "スタイライズ"), @@ -519,6 +810,7 @@ const GEORGIAN: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.3d.instance3d", "3D Layer Instance", "3D Layer Instance"), ("ec.audio.backwards", "Backwards", "უკუღმა"), ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), ("ec.audio.compressor", "Compressor", "კომპრესორი"), @@ -1001,3 +1293,21 @@ mod tests { assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } } + +#[cfg(test)] +mod japanese_category_tests { + use super::*; + use std::collections::HashMap; + + /// Two categories drawing the same Japanese name would make the Effect menu show one name twice and + /// leave the user unable to tell them apart. `描画` was both `Generate` and `Paint` before #2583. + #[test] + fn no_two_categories_share_a_japanese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, japanese) in JAPANESE_CATEGORIES { + if let Some(other) = seen.insert(japanese, english) { + panic!("`{english}` and `{other}` both draw as `{japanese}`"); + } + } + } +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index a7a9be62b..67a131d75 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -22,7 +22,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "pl", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -50,6 +50,7 @@ const ROWS: &[(&str, &[(&str, &str)])] = &[ ("pt-br", PORTUGUESE), ("cs", CZECH), ("es", SPANISH), + ("pl", POLISH), ("ka", GEORGIAN), ("ka", GEORGIAN_PANELS), ]; @@ -64,6 +65,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static PL: OnceLock = OnceLock::new(); static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), @@ -73,6 +75,7 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + "pl" => (&PL, POLISH), // Georgian also reads its own rows for text the other catalogs don't have yet. "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, @@ -92,6 +95,13 @@ pub(super) fn missing(language: &str) -> Vec { rows.chain(literals).filter(|en| !table.is_some_and(|c| c.contains_key(en.as_str())) && seen.insert(en.clone())).collect() } +/// The number of panel/dialog rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, Index::len) +} + /// The string literals `src` passes to `tr(` and `tr_args(`. #[cfg(test)] fn tr_literals(src: &str) -> Vec { @@ -197,6 +207,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -435,6 +446,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -468,7 +480,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("Overlay", "オーバーレイ"), ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), + ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセル縦横比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -902,6 +916,310 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Basic", "基本"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -939,6 +1257,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1168,6 +1487,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1201,7 +1521,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Overlay", "叠加"), ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), + ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1643,6 +1965,370 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Selection Tool", "选择工具"), + ("Hand Tool", "抓手工具"), + ("Zoom Tool", "缩放工具"), + ("Orbit Around Cursor Tool", "绕行摄像机工具"), + ("Pan Under Cursor Tool", "平移摄像机工具"), + ("Dolly Towards Cursor Tool", "推拉摄像机工具"), + ("Rotation Tool", "旋转工具"), + ("Pan Behind (Anchor Point) Tool", "向后平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圆角矩形工具"), + ("Ellipse Tool", "椭圆工具"), + ("Polygon Tool", "多边形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "钢笔工具"), + ("Add Vertex Tool", "添加顶点工具"), + ("Delete Vertex Tool", "删除顶点工具"), + ("Convert Vertex Tool", "转换顶点工具"), + ("Mask Feather Tool", "蒙版羽化工具"), + ("Horizontal Type Tool", "横排文字工具"), + ("Vertical Type Tool", "直排文字工具"), + ("Brush Tool", "画笔工具"), + ("Clone Stamp Tool", "仿制图章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 笔刷工具"), + ("Refine Edge Tool", "调整边缘工具"), + ("Object Matte Tool", "对象遮罩工具"), + ("Puppet Position Pin Tool", "人偶位置控点工具"), + ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), + ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), + ("Puppet Advanced Pin Tool", "人偶高级控点工具"), + ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), + ("Shortcut", "快捷键"), + ("Range", "范围"), + ("Play From", "从以下位置播放"), + ("Frame Rate", "帧速率"), + ("Resolution", "分辨率"), + ("Overlays", "叠加"), + ("Layer Controls", "图层控制"), + ("Loop", "循环"), + ("Cache Before Playback", "播放前缓存"), + ("Pre-roll", "预卷"), + ("Post-roll", "后卷"), + ("If caching, play cached frames", "缓存时播放已缓存帧"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1680,6 +2366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -1909,6 +2596,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -1942,7 +2630,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Overlay", "疊加"), ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), + ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2330,6 +3020,370 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Selection Tool", "選取工具"), + ("Hand Tool", "手形工具"), + ("Zoom Tool", "縮放工具"), + ("Orbit Around Cursor Tool", "繞行攝影機工具"), + ("Pan Under Cursor Tool", "平移攝影機工具"), + ("Dolly Towards Cursor Tool", "推拉攝影機工具"), + ("Rotation Tool", "旋轉工具"), + ("Pan Behind (Anchor Point) Tool", "向後平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圓角矩形工具"), + ("Ellipse Tool", "橢圓工具"), + ("Polygon Tool", "多邊形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "筆型工具"), + ("Add Vertex Tool", "添加頂點工具"), + ("Delete Vertex Tool", "刪除頂點工具"), + ("Convert Vertex Tool", "轉換頂點工具"), + ("Mask Feather Tool", "遮色片羽化工具"), + ("Horizontal Type Tool", "橫排文字工具"), + ("Vertical Type Tool", "垂直文字工具"), + ("Brush Tool", "筆刷工具"), + ("Clone Stamp Tool", "仿製圖章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 筆刷工具"), + ("Refine Edge Tool", "調整邊緣工具"), + ("Object Matte Tool", "物件遮色片工具"), + ("Puppet Position Pin Tool", "人偶位置控點工具"), + ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), + ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), + ("Puppet Advanced Pin Tool", "人偶高級控點工具"), + ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), + ("Shortcut", "快速鍵"), + ("Range", "範圍"), + ("Play From", "從下列位置播放"), + ("Frame Rate", "影格速率"), + ("Resolution", "解析度"), + ("Overlays", "覆疊"), + ("Layer Controls", "圖層控制"), + ("Loop", "循環"), + ("Cache Before Playback", "播放前快取"), + ("Pre-roll", "預卷"), + ("Post-roll", "後卷"), + ("If caching, play cached frames", "快取時播放已快取影格"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2610,6 +3664,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2643,6 +3698,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), + ("Track Matte pick whip: drag onto a layer", "Зв’язування трекової маски: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), ("Playing on Video Preview", "Відтворення на відеоперегляді"), ("Post-Render Action: {}", "Дія після рендерингу: {}"), @@ -2911,6 +3967,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ // Brazilian Portuguese. const PORTUGUESE: &[(&str, &str)] = &[ ("Click to sample color", "Clique para obter uma amostra de cor"), + ("Export Scene to Blender...", "Exportar cena para o Blender..."), (" OK ", " OK "), (" Delete ", " Excluir "), (" Open Auto-Save ", " Abrir salvamento automático "), @@ -3145,6 +4202,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3197,6 +4255,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Output To:", "Saída para:"), ("Parameter Name:", "Nome do parâmetro:"), ("Parent pick whip: drag onto a layer", "Pick whip de pai: arraste até uma camada"), + ("Track Matte pick whip: drag onto a layer", "Pick whip de track matte: arraste até uma camada"), ("Pixel Aspect Ratio:", "Proporção de pixel:"), ("Playing on Video Preview", "Reproduzindo na visualização de vídeo"), ("Post-Render Action: {}", "Ação pós-renderização: {}"), @@ -3358,6 +4417,1053 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("⚠ {} is used by:", "⚠ {} é usado por:"), ("Total Offset", "Deslocamento total"), ("Per Layer", "Por camada"), + ("Action-safe", "Área segura de ação"), + ("Add to favorites", "Adicionar aos favoritos"), + ("All Footage", "Todas as mídias"), + ("All Keyframes", "Todos os keyframes"), + ("Allow Scripts to Write Files and Access Network", "Permitir que scripts gravem arquivos e acessem a rede"), + ("Append Bit Depth to File Name", "Anexar profundidade de bits ao nome do arquivo"), + ("Application Scripting", "Scripting do aplicativo"), + ("Ask User", "Perguntar ao usuário"), + ("Audio Hardware", "Hardware de áudio"), + ("Audio Output Mapping", "Mapeamento de saída de áudio"), + ("Auto-Save Location", "Local do salvamento automático"), + ("Automatic", "Automático"), + ("Automatically Reload Footage", "Recarregar mídias automaticamente"), + ("Automatically Save Projects", "Salvar projetos automaticamente"), + ("Brightness", "Brilho"), + ("Cache Budgets", "Orçamentos de cache"), + ("Conformed Audio Folder", "Pasta de áudio conformado"), + ("Create Layers at Composition Start Time", "Criar camadas no tempo inicial da composição"), + ("Create Split Layers Above Original Layer", "Criar camadas divididas acima da camada original"), + ("Custom", "Personalizado"), + ("Custom Location", "Local personalizado"), + ("Cycle Mask Colors", "Alternar cores das máscaras"), + ("Dark Theme", "Tema escuro"), + ("Dashed Lines", "Linhas tracejadas"), + ("Default 3D Renderer", "Renderizador 3D padrão"), + ("Default Drag Import As", "Importação padrão ao arrastar como"), + ("Default Output Folder", "Pasta de saída padrão"), + ("Default Spatial Interpolation to Linear", "Definir interpolação espacial padrão como linear"), + ("Disable Video Output When in Background", "Desativar saída de vídeo em segundo plano"), + ("Disk Cache Folder", "Pasta de cache do disco"), + ("Display Color Space", "Espaço de cores da exibição"), + ("Dots", "Pontos"), + ("Duration (Seconds)", "Duração (segundos)"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "O EffectCraft sempre usa o próprio seletor de cores (o mesmo em todas as plataformas e na web).", + ), + ("Empty Disk Cache", "Esvaziar cache do disco"), + ("Enable Disk Cache", "Ativar cache do disco"), + ("Enable JavaScript Debugger", "Ativar depurador JavaScript"), + ("Enable Video Preview Output", "Ativar saída de pré-visualização de vídeo"), + ("Expression Pick Whip Writes Compact English", "Pick whip de expressão grava inglês compacto"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "As expressões usam o mecanismo JavaScript. Erros de script informam o arquivo e a linha no Console de scripts; não há depurador passo a passo.", + ), + ("Face Model", "Modelo de rosto"), + ("Face Tracking", "Rastreamento facial"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "O rastreamento facial no painel Rastreador (Somente contorno e recursos detalhados) usa o modelo escolhido aqui. Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.", + ), + ("Fast Previews (Draft 3D, Faster Effects)", "Pré-visualizações rápidas (3D em rascunho, efeitos mais rápidos)"), + ("Files per Segment", "Arquivos por segmento"), + ("Fit", "Ajustar"), + ("Fit up to 100%", "Ajustar até 100%"), + ("Floating Window", "Janela flutuante"), + ("Footage Frame Cache", "Cache de quadros de mídia"), + ("GPU Information...", "Informações da GPU..."), + ("Gridline Every", "Grade a cada"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Aceleração por hardware dos painéis de composição, camada e mídia"), + ("Import Photoshop File", "Importar arquivo do Photoshop"), + ("Interpret Unlabeled Alpha As", "Interpretar alfa não rotulado como"), + ("Keyframes", "Keyframes"), + ("Labels and Colors", "Rótulos e cores"), + ("Layer Cache", "Cache de camada"), + ("Length of Composition", "Duração da composição"), + ("Levels of Undo", "Níveis de desfazer"), + ("Light Theme", "Tema claro"), + ("Match System", "Seguir o sistema"), + ("Maximum Disk Cache Size", "Tamanho máximo do cache do disco"), + ("Maximum Project Versions", "Máximo de versões do projeto"), + ("Media Cache", "Cache de mídia"), + ("Menu Bar", "Barra de menus"), + ("Minutes", "Minutos"), + ("Mirror on Computer Monitor", "Espelhar no monitor do computador"), + ("Move Panel to New Window", "Mover painel para nova janela"), + ("New Composition", "Nova composição"), + ("New Project", "Novo projeto"), + ("New Project Loads Template", "Novo projeto carrega modelo"), + ("Next to Project", "Ao lado do projeto"), + ("No More Than (Keyframes)", "No máximo (keyframes)"), + ("No More Than (Seconds)", "No máximo (segundos)"), + ("No Motion Path", "Sem caminho de movimento"), + ("Non-Sequence Footage", "Mídia que não é sequência"), + ("Number of Recent Fonts to Display", "Número de fontes recentes a exibir"), + ("Off (Final Quality)", "Desativado (qualidade final)"), + ("Offer to Open the Latest Auto-Save After a Crash", "Oferecer para abrir o salvamento automático mais recente após uma falha"), + ("OpenGL (compatibility)", "OpenGL (compatibilidade)"), + ("Panels are always drawn by the GPU (wgpu).", "Os painéis são sempre desenhados pela GPU (wgpu)."), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Preservar o número constante de vértices e pontos de feather ao editar máscaras"), + ("Preview (RAM) Cache", "Cache de pré-visualização (RAM)"), + ("RAM Reserved for Other Applications", "RAM reservada para outros aplicativos"), + ("RGB Straight", "RGB direto"), + ("Realtime Shadows in Draft", "Sombras em tempo real no rascunho"), + ("Recent Projects Shown", "Projetos recentes exibidos"), + ("Red", "Vermelho"), + ("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"), + ("Remove from favorites", "Remover dos favoritos"), + ("Repair", "Reparo"), + ("Report Missing Frames", "Relatar quadros ausentes"), + ("Reset Keyboard Shortcuts", "Redefinir atalhos de teclado"), + ("Reset Settings", "Redefinir configurações"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Roto Brush 2.0 e 3.0 usam o modelo escolhido aqui (a versão 1.0 sempre usa o mecanismo clássico). Os modelos são de código aberto, baixados somente quando você solicita, e verificados com o SHA-256 publicado.", + ), + ("Safe Margins", "Margens de segurança"), + ("Save Every", "Salvar a cada"), + ("Save Now", "Salvar agora"), + ("Save When Starting Render Queue", "Salvar ao iniciar a fila de renderização"), + ("Segment Movie Files", "Segmentar arquivos de filme"), + ("Segment Sequences", "Segmentar sequências"), + ("Segment Size", "Tamanho do segmento"), + ("Segmentation Model", "Modelo de segmentação"), + ("Sequence Footage", "Mídia em sequência"), + ("Show 3D Reference Axes", "Mostrar eixos de referência 3D"), + ("Show Expression Error Banner", "Mostrar faixa de erro de expressão"), + ("Show Font Names in English", "Mostrar nomes de fontes em inglês"), + ("Show Font Preview", "Mostrar pré-visualização de fonte"), + ("Show Home Screen When Launching", "Mostrar tela inicial ao iniciar"), + ("Show Home Screen When Opening a Project", "Mostrar tela inicial ao abrir um projeto"), + ("Show Tool Tips", "Mostrar dicas de ferramenta"), + ("Startup & Repair", "Inicialização e reparo"), + ("Still Footage", "Mídia estática"), + ("Stored, but not used by EffectCraft yet", "Armazenado, mas ainda não usado pelo EffectCraft"), + ("Subdivisions", "Subdivisões"), + ("Switches Affect Nested Comps", "Interruptores afetam composições aninhadas"), + ("Synchronize Time of All Related Items", "Sincronizar o tempo de todos os itens relacionados"), + ("Template Project", "Projeto de modelo"), + ("Title-safe", "Área segura de título"), + ("Use In-Window Menu Bar on macOS", "Usar barra de menus na janela no macOS"), + ("Use System Color Picker", "Usar seletor de cores do sistema"), + ("Video Device", "Dispositivo de vídeo"), + ("Video Output During Playback", "Saída de vídeo durante a reprodução"), + ("Warn User When Executing Files", "Avisar o usuário ao executar arquivos"), + ("Window Graphics", "Gráficos da janela"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Gráficos da janela vale a partir do próximo início. Quando o driver gráfico interrompe o EffectCraft na abertura da janela, o próximo início muda para OpenGL.", + ), + ("Wireframe", "Wireframe"), + ("Active", "Em uso"), + ("NAME", "NOME"), + ("Affect all layers that use this solid", "Afetar todas as camadas que usam este sólido"), + ("EffectCraft home page", "Página inicial do EffectCraft"), + ("Only this layer uses this solid.", "Somente esta camada usa este sólido."), + ("KIND", "TIPO"), + ("Blue", "Azul"), + ("Green", "Verde"), + ("Unfreeze", "Descongelar"), + ("Export", "Exportar"), + ("Auto-complete", "Preenchimento automático"), + ("Bracket Matching", "Correspondência de colchetes"), + ("Font Size", "Tamanho da fonte"), + ("Line Numbers", "Números de linha"), + ("Scripting & Expressions", "Scripts e expressões"), + ("Syntax Highlighting", "Realce de sintaxe"), + ("Word Wrap", "Quebra automática de linha"), + ("16 bits per channel", "16 bits por canal"), + ("32 bits per channel (float)", "32 bits por canal (ponto flutuante)"), + ("8 bits per channel", "8 bits por canal"), + ("Adobe-style built-in", "Integrado (estilo Adobe)"), + ("Composition - Retain Layer Sizes", "Composição - Manter tamanhos de camada"), + ("Guess", "Adivinhar"), + ("Import Image Sequence", "Importar sequência de imagens"), + ("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"), + ("Mercury Software Only", "Apenas software Mercury"), + ("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"), + ("Still Duration", "Duração da imagem fixa"), + ("Straight", "Direto"), + ("Tone map", "Mapeamento tonal"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (padrão)"), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Clique acima da barra para adicionar uma parada de opacidade, abaixo dela para adicionar uma parada de cor.", + ), + ("Location:", "Local:"), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Estes keyframes adjacentes não podem ser interpolados porque os seus números de paradas diferem.", + ), + ("After Effects project", "Projeto do After Effects"), + ("Create a new project, or open the demo project to look around.", "Crie um novo projeto ou abra o projeto de demonstração para explorar."), + ("Filter recent files", "Filtrar arquivos recentes"), + ("Import Footage…", "Importar mídia…"), + ("Just now", "Agora mesmo"), + ("Lottie animation", "Animação Lottie"), + ("Motion graphics and visual effects · version {}", "Motion graphics e efeitos visuais · versão {}"), + ("No recent files match the filter", "Nenhum arquivo recente corresponde ao filtro"), + ("OPENED", "ABERTO"), + ("Open Demo Project", "Abrir projeto de demonstração"), + ("Open Project…", "Abrir projeto…"), + ("SIZE", "TAMANHO"), + ("Try another name or folder.", "Tente outro nome ou pasta."), + ("Yesterday", "Ontem"), + ("Your recent files will show here", "Seus arquivos recentes aparecerão aqui"), + ("{} day ago", "há {} dia"), + ("{} days ago", "há {} dias"), + ("{} hour ago", "há {} hora"), + ("{} hours ago", "há {} horas"), + ("{} minute ago", "há {} minuto"), + ("{} minutes ago", "há {} minutos"), + ("Show Rendering Progress in Info Panel and Flowchart", "Mostrar progresso de renderização no painel Informações e no fluxograma"), + ("Current Settings", "Configurações atuais"), + ("Move Panel to Main Window", "Mover painel para a janela principal"), + ("Disable Per-character 3D", "Desativar 3D por caractere"), + ("Enable Per-character 3D", "Ativar 3D por caractere"), + ("Opus Bitrate: {} kbps", "Taxa de bits do Opus: {} kbps"), + ("Quarter", "Um quarto"), + ("Region of Interest", "Região de interesse"), + ("Third", "Um terço"), + ("Boundary", "Contorno"), + ("Labels", "Rótulos"), + ("You can't undo this.", "Você não pode desfazer isto."), + ("Latin", "Latino"), + ("South Asian and Middle Eastern", "Sul-asiático e do Oriente Médio"), + ("Text Engine", "Motor de texto"), + ("Variable fonts only", "Apenas fontes variáveis"), + ("Expressions Editor", "Editor de expressões"), + ("Faster", "Mais rápido"), + ("Grids & Guides", "Grades e guias"), + ("More Accurate", "Mais preciso"), + ("Path Point and Handle Size", "Tamanho dos pontos de caminho e das alças"), + ("Show Internal Wireframes", "Mostrar wireframes internos"), + ("Viewer Zoom Quality", "Qualidade de zoom do visualizador"), + ("Motion Path", "Caminho de movimento"), + ("3D Reference Axes", "Eixos de referência 3D"), + ("Choose grid and guide options", "Escolher opções de grade e guias"), + ("Colorize", "Colorir"), + ("Custom...", "Personalizado..."), + ("Proportional Grid", "Grade proporcional"), + ("Reset Exposure", "Redefinir exposição"), + ("Show Channel and Color Management Settings", "Mostrar configurações de canal e de gerenciamento de cores"), + ("Show Snapshot (hold)", "Mostrar instantâneo (manter pressionado)"), + ("Take Snapshot", "Capturar instantâneo"), + ("Toggle Mask and Shape Path Visibility", "Alternar visibilidade das máscaras e dos caminhos de forma"), + ("Toggle Transparency Grid", "Alternar grade de transparência"), + ("Database and Cache Folder", "Pasta de banco de dados e de cache"), + ("Adaptive Resolution", "Resolução adaptável"), + ("Adaptive Resolution Limit", "Limite de resolução adaptável"), + ("Alpha", "Alfa"), + ("Appearance", "Aparência"), + ("Appearance Mode", "Modo de aparência"), + ("Appearance Mode: {}", "Modo de aparência: {}"), + ("Appearance Settings…", "Configurações de aparência…"), + ("Audio", "Áudio"), + ("Auto-Save", "Auto-salvar"), + ("Cache Frames When Idle", "Armazenar quadros em cache quando ocioso"), + ("Dark", "Escuro"), + ("Default", "Padrão"), + ("Disk", "Disco"), + ("Disk Cache", "Cache do disco"), + ("Display", "Exibição"), + ("Draft", "Rascunho"), + ("EffectCraft on GitHub", "EffectCraft no GitHub"), + ("EffectCraft project", "Projeto do EffectCraft"), + ("Extended Viewer", "Visualizador estendido"), + ("Fast Draft", "Rascunho rápido"), + ("Footage", "Mídia"), + ("Freeze", "Congelar"), + ("Full", "Integral"), + ("Full Screen", "Tela inteira"), + ("General", "Geral"), + ("Gradient Editor", "Editor de gradiente"), + ("Half", "Metade"), + ("Horizontal", "Horizontal"), + ("Ignore", "Ignorar"), + ("Join the ArtCraft Discord", "Entre no Discord da ArtCraft"), + ("Language", "Idioma"), + ("Left", "Esquerda"), + ("Light", "Claro"), + ("Lines", "Linhas"), + ("Memory & CPU", "Memória e CPU"), + ("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"), + ("Off", "Desativado"), + ("Options", "Opções"), + ("Overlay", "Sobrepor"), + ("Premultiplied", "Pré-multiplicado"), + ("Preview Sample Rate", "Taxa de amostragem de pré-visualização"), + ("Previews", "Pré-visualizações"), + ("Project", "Projeto"), + ("RGB", "RGB"), + ("Right", "Direita"), + ("Roto Brush", "Roto Brush"), + ("Seconds", "Segundos"), + ("Stop", "Parar"), + ("Style", "Estilo"), + ("Sync with System", "Sincronizar com o sistema"), + ("Templates", "Modelos"), + ("Track", "Rastrear"), + ("UI Scale", "Escala da interface"), + ("Use Gradients", "Usar gradientes"), + ("Use Label Color for Layer Handles and Paths", "Usar a cor do rótulo nas alças de camada e nos caminhos"), + ("Use Label Color for Related Tabs", "Usar a cor do rótulo nas abas relacionadas"), + ("Vertical", "Vertical"), + ("Video", "Vídeo"), + ("channel", "canal"), + ("files", "arquivos"), +]; + +/// Polish (pl): original translations covering every row in the Chinese panel catalogs. +const POLISH: &[(&str, &str)] = &[ + ("Click to sample color", "Kliknij, aby pobrać próbkę koloru"), + ("Appearance Settings…", "Ustawienia wyglądu…"), + ("Add to favorites", "Dodaj do ulubionych"), + ("Remove from favorites", "Usuń z ulubionych"), + (" OK ", " OK "), + (" Delete ", " Usuń "), + (" Open Auto-Save ", " Otwórz autozapis "), + ("(missing)", "(brak)"), + ("360 View Orientation", "Orientacja widoku 360"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompozytor grafiki ruchomej i efektów wizualnych, open source i tworzony metodą clean-room: natywny w macOS, Windows i Linuksie oraz w przeglądarce. Część rodziny aplikacji twórczych ArtCraft.", + ), + ("About EffectCraft", "O programie EffectCraft"), + ("About...", "O programie..."), + ("Active", "Aktywny"), + ("Active Camera", "Aktywna kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Dostosuj cechę w każdej klatce"), + ("Adaptive Sample Limit:", "Adaptacyjny limit próbek:"), + ("Add Animator", "Dodaj animator"), + ("Add Comment", "Dodaj komentarz"), + ("Add Formatting", "Dodaj formatowanie"), + ("Add Group", "Dodaj grupę"), + ("Add Mirror", "Dodaj lustro"), + ("Add Selected As", "Dodaj zaznaczone jako"), + ("Add a comment", "Dodaj komentarz"), + ("Add a shape, fill, stroke or path operation", "Dodaj kształt, wypełnienie, obrys lub operację na ścieżce"), + ("Add to Composition", "Dodaj do kompozycji"), + ("Add to Favorites", "Dodaj do ulubionych"), + ("Add:", "Dodaj:"), + ("Adjust Exposure (stops)", "Dostosuj ekspozycję (EV)"), + ("Adjust composition duration to the time span of the selected layers", "Dopasuj czas trwania kompozycji do zakresu czasu zaznaczonych warstw"), + ("Advanced 3D", "Zaawansowane 3D"), + ("Align Layers to:", "Wyrównaj warstwy do:"), + ("All six face cameras turn together; the scene is not changed.", "Wszystkie sześć kamer ścian obraca się razem; scena się nie zmienia."), + ("Anchor:", "Zakotwiczenie:"), + ("Aperture", "Przysłona"), + ("Appearance Mode", "Tryb wyglądu"), + ("Appearance Mode: {}", "Tryb wyglądu: {}"), + ("Apply Dimensions:", "Zastosuj wymiary:"), + ("Apply Motion To:", "Zastosuj ruch do:"), + ("Arrange", "Kolejność"), + ("Assign", "Przypisz"), + ("Auto", "Auto"), + ("Auto-saves", "Autozapisy"), + ("Back", "Tył"), + ("Background Color:", "Kolor tła:"), + ("Blend Mode:", "Tryb mieszania:"), + ("Blending Mode", "Tryb mieszania"), + ("Blur", "Rozmycie"), + ("Blur Level", "Poziom rozmycia"), + ("Boundary", "Granica"), + ("Browser Storage", "Pamięć przeglądarki"), + ("Browse…", "Przeglądaj…"), + ("Brush Dynamics", "Dynamika pędzla"), + ("Caching frames…", "Buforowanie klatek…"), + ("Cancel", "Anuluj"), + ("Casts Shadows", "Rzuca cienie"), + ("Change Renderer…", "Zmień renderer…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Zmiana skrótu tworzy niestandardowe ustawienie; domyślne pozostaje bez zmian.", + ), + ("Channel", "Kanał"), + ("Channel:", "Kanał:"), + ("Chapter:", "Rozdział:"), + ("Choose...", "Wybierz..."), + ("Classic 3D", "Klasyczne 3D"), + ("Clear", "Wyczyść"), + ("Clear list", "Wyczyść listę"), + ("Click a key to see its commands.", "Kliknij klawisz, aby zobaczyć jego polecenia."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Kliknij nad paskiem, aby dodać punkt krycia, pod nim, aby dodać punkt koloru.", + ), + ("Click adds a point (½ height = no change)", "Kliknięcie dodaje punkt (½ wysokości = bez zmian)"), + ("Click adds a point; drag one off to remove", "Kliknięcie dodaje punkt; przeciągnij go poza obszar, aby usunąć"), + ("Click to make this viewer active", "Kliknij, aby uaktywnić tę przeglądarkę"), + ("Click to reveal the expression in the timeline", "Kliknij, aby pokazać wyrażenie na osi czasu"), + ("Click to show the active composition here", "Kliknij, aby pokazać tu aktywną kompozycję"), + ("Click, then click in the Composition panel", "Kliknij, a potem kliknij w panelu Kompozycja"), + ("Clone Options", "Opcje klonowania"), + ("Clone Source Overlay:", "Nakładka źródła klonowania:"), + ("Close", "Zamknij"), + ("Close Panel", "Zamknij panel"), + ("Close tutorial", "Zamknij samouczek"), + ("Color", "Kolor"), + ("Color Depth: {}", "Głębia kolorów: {}"), + ("Color:", "Kolor:"), + ("Colour map", "Mapa kolorów"), + ("Columns", "Kolumny"), + ("Comment:", "Komentarz:"), + ("Community", "Społeczność"), + ("Composition", "Kompozycja"), + ("Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("Composition Name:", "Nazwa kompozycji:"), + ("Composition Settings ▸ 3D Renderer", "Ustawienia kompozycji ▸ Renderer 3D"), + ("Composition Settings…", "Ustawienia kompozycji…"), + ("Composition marker bin: drag a marker to the time ruler", "Pojemnik znaczników kompozycji: przeciągnij znacznik na linijkę czasu"), + ("Cone Angle", "Kąt stożka"), + ("Cone Feather", "Wtapianie stożka"), + ("Constrain Proportions", "Zachowaj proporcje"), + ("Continuous (Lock Outgoing to Incoming)", "Ciągły (zablokuj wyjście do wejścia)"), + ("Cue Point", "Punkt sygnału"), + ("Current Render", "Bieżące renderowanie"), + ("Dark", "Ciemny"), + ("Dark Theme", "Ciemny motyw"), + ("Darker", "Ciemniej"), + ("Default Output:", "Domyślne wyjście:"), + ("Default: {}", "Domyślnie: {}"), + ("Defaults", "Domyślne"), + ("Delete", "Usuń"), + ("Delete Selected Points", "Usuń zaznaczone punkty"), + ("Delete selected project items", "Usuń zaznaczone elementy projektu"), + ("Delete this template", "Usuń ten szablon"), + ("Depth of Field", "Głębia ostrości"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmieść warstwy:"), + ("Don't Recover", "Nie odzyskuj"), + ("Don't Save", "Nie zapisuj"), + ("Double-click a layer to open it in the Layer panel", "Kliknij dwukrotnie warstwę, aby otworzyć ją w panelu Warstwa"), + ("Double-click footage in the Project panel to open it here", "Kliknij dwukrotnie materiał w panelu Projekt, aby otworzyć go tutaj"), + ("Double-click to edit the comment", "Kliknij dwukrotnie, aby edytować komentarz"), + ("Download", "Pobierz"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Przeciągnij tu właściwości z osi czasu\nalbo użyj Animacja > Dodaj właściwość do Essential Graphics.", + ), + ("Drag to change", "Przeciągnij, aby zmienić"), + ("Drag to create a guide", "Przeciągnij, aby utworzyć linię pomocniczą"), + ("Drag to slip the source", "Przeciągnij, aby przesunąć źródło"), + ("Drop Frame", "Z pomijaniem klatek"), + ("Duplicate", "Powiel"), + ("Duration:", "Czas trwania:"), + ("Edit Gradient…", "Edytuj gradient…"), + ("Edit Target: {}", "Cel edycji: {}"), + ("Edit in the Timeline", "Edytuj na osi czasu"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), + ("Effects & Presets options", "Opcje efektów i ustawień"), + ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Pusty folder"), + ("Enable Depth of Field", "Włącz głębię ostrości"), + ("Enable Expression", "Włącz wyrażenie"), + ("Enhance", "Ulepsz"), + ("Essential Properties — {}", "Najważniejsze właściwości — {}"), + ("Event", "Zdarzenie"), + ("Export Template…", "Eksportuj szablon…"), + ("Export...", "Eksportuj..."), + ("Expression Language Menu", "Menu języka wyrażeń"), + ("Expression pick whip: drag onto a property", "Łącznik wyrażenia: przeciągnij na właściwość"), + ("Expression:", "Wyrażenie:"), + ("Eyedropper: click, then click in the Composition panel", "Kroplomierz: kliknij, a potem kliknij w panelu Kompozycja"), + ("Falloff", "Zanikanie"), + ("Falloff Distance", "Odległość zanikania"), + ("Favorites", "Ulubione"), + ("Field Render: {}", "Renderowanie pól: {}"), + ("Figures", "Cyfry"), + ("Fill Target", "Cel wypełnienia"), + ("Finished", "Zakończono"), + ("Flowchart: {}", "Schemat: {}"), + ("Focus Distance", "Odległość ostrości"), + ("Follow Feature Rotation and Scale", "Podążaj za obrotem i skalą cechy"), + ("Footage: {}", "Materiał: {}"), + ("Format: {}", "Format: {}"), + ("Frame Aspect Ratio: {}", "Proporcje klatki: {}"), + ("Frame Blending: {}", "Mieszanie klatek: {}"), + ("Frame Rate:", "Liczba klatek na sekundę:"), + ("Frame Render Time {}ms", "Czas renderowania klatki {}ms"), + ("Frame Target:", "Cel klatki:"), + ("Freeze", "Zamroź"), + ("Grab bag", "Różności"), + ("Gradient Editor", "Edytor gradientu"), + ("Grid", "Siatka"), + ("Guide Layers: {}", "Warstwy pomocnicze: {}"), + ("Guides", "Linie pomocnicze"), + ("Height:", "Wysokość:"), + ("Hide This", "Ukryj to"), + ("Histogram", "Histogram"), + ("Hold In Place", "Zatrzymaj w miejscu"), + ("Home", "Start"), + ("If Confidence is Below", "Jeśli pewność jest poniżej"), + ("Import", "Importuj"), + ("Import and Add to Composition", "Importuj i dodaj do kompozycji"), + ("Import...", "Importuj..."), + ("Imported media", "Zaimportowane multimedia"), + ("Influence:", "Wpływ:"), + ("Install from File…", "Zainstaluj z pliku…"), + ("Intensity", "Natężenie"), + ("Inverted", "Odwrócone"), + ("Join the ArtCraft community on Discord", "Dołącz do społeczności ArtCraft na Discordzie"), + ("Key: {}", "Klawisz: {}"), + ("LEARN", "NAUKA"), + ("Label", "Etykieta"), + ("Label Colors and Names:", "Kolory i nazwy etykiet:"), + ("Label:", "Etykieta:"), + ("Latest auto-save: {}", "Ostatni autozapis: {}"), + ("Layer", "Warstwa"), + ("Layer controls", "Elementy sterujące warstwy"), + ("Layer:", "Warstwa:"), + ("Layer: {}", "Warstwa: {}"), + ("Learn", "Nauka"), + ("Leave all attributes in \"{}\"", "Zachowaj wszystkie atrybuty w „{}”"), + ("Licence: {}", "Licencja: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Jasny"), + ("Light Theme", "Jasny motyw"), + ("Light Type", "Typ światła"), + ("Lighter", "Jaśniej"), + ("Link Selected Property", "Połącz zaznaczoną właściwość"), + ("Location:", "Lokalizacja:"), + ("Lock Aspect Ratio to {}", "Zablokuj proporcje na {}"), + ("Lock to Zoom", "Zablokuj do powiększenia"), + ("Locked: {} — {}", "Zablokowano: {} — {}"), + ("Log:", "Dziennik:"), + ("Loop (Trace Path)", "Pętla (śledź ścieżkę)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Czcionki: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu współczynnika powiększenia"), + ("Make Comp Size", "Ustaw rozmiar kompozycji"), + ("Markers", "Znaczniki"), + ("Masks", "Maski"), + ("Maximize Panel", "Maksymalizuj panel"), + ("Media Replacement", "Zamiana multimediów"), + ("Mesh:", "Siatka:"), + ("Diameter:", "Średnica:"), + ("Method Used: {} Average Error: {} pixels", "Użyta metoda: {} Średni błąd: {} px"), + ("Models folder: {}", "Folder modeli: {}"), + ("Modifiers:", "Modyfikatory:"), + ("More", "Więcej"), + ("More ArtCraft apps", "Więcej aplikacji ArtCraft"), + ("More tutorials", "Więcej samouczków"), + ("Motion Blur", "Rozmycie ruchu"), + ("Motion Blur: {}", "Rozmycie ruchu: {}"), + ("Motion Sketch: drag to record", "Szkic ruchu: przeciągnij, aby nagrać"), + ("Move Down", "Przesuń w dół"), + ("Move Up", "Przesuń w górę"), + ("Move to Project Root", "Przenieś do katalogu głównego projektu"), + ("Name", "Nazwa"), + ("Name:", "Nazwa:"), + ("Navigation", "Nawigacja"), + ("New", "Nowy"), + ("New Comp from Selection", "Nowa kompozycja z zaznaczenia"), + ("Replace with Precomposition", "Zastąp kompozycją wstępną"), + ("New Duration:", "Nowy czas trwania:"), + ("New composition name:", "Nazwa nowej kompozycji:"), + ("New from Render Queue item:", "Nowy z elementu kolejki renderowania:"), + ("New from Template", "Nowy z szablonu"), + ("Next", "Dalej"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Dobra robota. Wybierz kolejny samouczek na karcie Nauka ekranu startowego."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Brak środowisk VR. Utwórz je poleceniem Kompozycja ▸ VR ▸ Utwórz środowisko VR…", + ), + ("No audio layer selected", "Nie zaznaczono warstwy audio"), + ("No background jobs running", "Brak zadań w tle"), + ("No composition markers", "Brak znaczników kompozycji"), + ("No contributor data was built into this copy.", "Ta kopia nie zawiera danych o współtwórcach."), + ("No device list from this host; the system default output is used.", "Ten system nie podaje listy urządzeń; używane jest domyślne wyjście systemowe."), + ("No layer selected", "Nie zaznaczono warstwy"), + ("No matching effects or presets", "Brak pasujących efektów lub ustawień"), + ("No matching fonts", "Brak pasujących czcionek"), + ("Variable fonts only", "Tylko czcionki zmienne"), + ("No model credits were built into this copy.", "Ta kopia nie zawiera informacji o modelach."), + ("No nested compositions", "Brak zagnieżdżonych kompozycji"), + ("No shortcut", "Brak skrótu"), + ("No unlocked layer selected", "Nie zaznaczono odblokowanej warstwy"), + ("No video", "Brak wideo"), + ("None", "Brak"), + ("Not applied: tick it in the list to add it.", "Niezastosowany: zaznacz go na liście, aby go dodać."), + ("Not assigned", "Nieprzypisany"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Uwaga: cienie rzucają tylko warstwy z włączoną opcją Rzuca cienie."), + ("Notify", "Powiadom"), + ("OK", "OK"), + ("Offset:", "Przesunięcie:"), + ("On ({}) stop:", "Punkt na ({}):"), + ("One-Node Camera", "Kamera jednowęzłowa"), + ("Opacity:", "Krycie:"), + ("Open Composition", "Otwórz kompozycję"), + ("used once", "użyty raz"), + ("used {} times", "użycia: {}"), + ("Compositions that use this item", "Kompozycje, które używają tego elementu"), + ("Open Last Saved Project", "Otwórz ostatnio zapisany projekt"), + ("Open New Composition", "Otwórz nową kompozycję"), + ("Open Output", "Otwórz wynik"), + ("Open a composition to build Essential Graphics", "Otwórz kompozycję, aby zbudować Essential Graphics"), + ("Open a composition to see its scopes", "Otwórz kompozycję, aby zobaczyć jej zakresy"), + ("Open a composition to track motion.", "Otwórz kompozycję, aby śledzić ruch."), + ("Opus Tuning: {}", "Strojenie Opus: {}"), + ("Original Duration:", "Oryginalny czas trwania:"), + ("Output Cycle: {}", "Cykl wyjścia: {}"), + ("Output To:", "Wyjście do:"), + ("Overlay", "Nakładka"), + ("Parameter Name:", "Nazwa parametru:"), + ("Parent pick whip: drag onto a layer", "Łącznik rodzica: przeciągnij na warstwę"), + ("Pixel Aspect Ratio:", "Proporcje piksela:"), + ("Playing on Video Preview", "Odtwarzanie w podglądzie wideo"), + ("Post-Render Action: {}", "Akcja po renderowaniu: {}"), + ("Preserve frame rate when nested or in render queue", "Zachowaj liczbę klatek po zagnieżdżeniu lub w kolejce renderowania"), + ("Preserve resolution when nested", "Zachowaj rozdzielczość po zagnieżdżeniu"), + ("Preset", "Ustawienie"), + ("Preset:", "Ustawienie:"), + ("Presets", "Ustawienia"), + ("Previous", "Wstecz"), + ("Primary:", "Główny:"), + ("Process Before Match", "Przetwarzaj przed dopasowaniem"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Głębia kolorów projektu: kliknij, aby przełączać 8/16/32 bpc, Alt-klik otwiera Ustawienia projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Łącznik właściwości: przeciągnij na właściwość"), + ("Protected Region", "Obszar chroniony"), + ("Proxy Use: {}", "Użycie proxy: {}"), + ("Push All to Comp", "Wypchnij wszystko do kompozycji"), + ("RGB", "RGB"), + ("Radius", "Promień"), + ("Rate Control: {}", "Kontrola przepływności: {}"), + ("Recent", "Ostatnie"), + ("Recording", "Nagrywanie"), + ("Remove", "Usuń"), + ("Rename", "Zmień nazwę"), + ("Render: {} ms • UI {} fps{}", "Renderowanie: {} ms • UI {} fps{}"), + ("Renderer:", "Renderer:"), + ("Request Persistent Storage", "Poproś o trwałą pamięć"), + ("Reset", "Resetuj"), + ("Reset Labels", "Resetuj etykiety"), + ("Reset Preset", "Resetuj ustawienie"), + ("Reset to Default", "Przywróć domyślne"), + ("Reset to Saved Layout", "Przywróć zapisany układ"), + ("Resolution/Down Sample Factor Popup", "Menu rozdzielczości/współczynnika próbkowania"), + ("Restore Panel Size", "Przywróć rozmiar panelu"), + ("Reverse the order", "Odwróć kolejność"), + ("Revert All", "Przywróć wszystko"), + ("Roman Hanging Punctuation", "Interpunkcja wisząca (łacińska)"), + ("Roving:", "Wędrujące:"), + ("Ruler zero point (drag to move, double-click to reset)", "Punkt zerowy linijki (przeciągnij, aby przesunąć, kliknij dwukrotnie, aby zresetować)"), + ("Rulers", "Linijki"), + ("Run", "Uruchom"), + ("Sample Format: {}", "Format próbek: {}"), + ("Samples Per Frame:", "Próbek na klatkę:"), + ("Save from #{}", "Zapisz od #{}"), + ("Saved {}", "Zapisano {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skrypt… (⌘/Ctrl+Enter uruchamia)"), + ("Search commands or shortcuts", "Szukaj poleceń lub skrótów"), + ("Select", "Zaznacz"), + ("Select a command to change its shortcut.", "Zaznacz polecenie, aby zmienić jego skrót."), + ("Select a layer to see its effects", "Zaznacz warstwę, aby zobaczyć jej efekty"), + ("Select a layer to see its properties", "Zaznacz warstwę, aby zobaczyć jej właściwości"), + ("({}) Selected Objects", "Zaznaczone obiekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Zaznacz warstwę z maską lub ścieżką kształtu (albo właściwość ścieżki) lub pinezki Puppet.", + ), + ("Select a text layer", "Zaznacz warstwę tekstową"), + ("Select an item in the Project panel", "Zaznacz element w panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Zaznacz animowane właściwości, aby pokazać je w edytorze wykresów"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Zaznacz pinezkę prowadzącą, a potem Shift-kliknij pinezki, które mają za nią podążać (włosy, tkanina, ogony)", + ), + ("Set Ground Plane and Origin", "Ustaw płaszczyznę podłoża i początek układu"), + ("Set Paint Colors to Black and White (D)", "Ustaw kolory malowania na czarny i biały (D)"), + ("Settings", "Ustawienia"), + ("Settings Name:", "Nazwa ustawień:"), + ("Shadow Darkness", "Ciemność cienia"), + ("Shadow Diffusion", "Rozproszenie cienia"), + ("Shadows", "Cienie"), + ("Shape…", "Kształt…"), + ("Show", "Pokaż"), + ("Show Layer Name", "Pokaż nazwę warstwy"), + ("Show Source Name", "Pokaż nazwę źródła"), + ("Show me", "Pokaż mi"), + ("Show:", "Pokaż:"), + ("Shutter Angle:", "Kąt migawki:"), + ("Shutter Phase:", "Faza migawki:"), + ("Size", "Rozmiar"), + ("Skip", "Pomiń"), + ("Snapping", "Przyciąganie"), + ("Snapping options", "Opcje przyciągania"), + ("Snapshot", "Migawka"), + ("Solo Supported Properties", "Solo dla obsługiwanych właściwości"), + ("Solo Switches: {}", "Przełączniki solo: {}"), + ("Sort", "Sortuj"), + ("Spacing:", "Odstęp:"), + ("Spatial Interpolation:", "Interpolacja przestrzenna:"), + ("Speed:", "Szybkość:"), + ("Start Timecode:", "Początkowy kod czasowy:"), + ("Start: {} Duration: {}", "Początek: {} Czas trwania: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zatrzymaj"), + ("Stop {} at {}°", "Punkt {} na {}°"), + ("Stored in: {}", "Przechowywane w: {}"), + ("Stretch", "Rozciągnij"), + ("Stretch Factor:", "Współczynnik rozciągnięcia:"), + ("Stylistic Sets", "Zestawy stylistyczne"), + ("Subpixel Positioning", "Pozycjonowanie podpikselowe"), + ("Swap Colors", "Zamień kolory"), + ("Swap Paint Colors (X)", "Zamień kolory malowania (X)"), + ("Sync with System", "Synchronizuj z systemem"), + ("System Default", "Domyślne systemowe"), + ("Table", "Tabela"), + ("Template name", "Nazwa szablonu"), + ("Template: {}\n{}", "Szablon: {}\n{}"), + ("Temporal Interpolation:", "Interpolacja czasowa:"), + ("The Media Browser is not available here (use File ▸ Import)", "Przeglądarka multimediów nie jest tu dostępna (użyj Plik ▸ Importuj)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Warstwa OpenEXR pokazana w kanałach czerwonym, zielonym, niebieskim i alfa"), + ("The render queue is empty.", "Kolejka renderowania jest pusta."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Te sąsiednie klatki kluczowe nie mogą być interpolowane, bo mają różną liczbę punktów.", + ), + ("This ScriptUI panel has closed", "Ten panel ScriptUI został zamknięty"), + ("This change will create a new solid for this layer.", "Ta zmiana utworzy nową planszę dla tej warstwy."), + ("Time", "Czas"), + ("Time Stretch…", "Rozciągnięcie czasu…"), + ("Time:", "Czas:"), + ("Time: {}", "Czas: {}"), + ("Title/Action Safe", "Obszar bezpieczny tytułów/akcji"), + ("Title/action safe", "Obszar bezpieczny tytułów/akcji"), + ("Toggle Switches / Modes", "Przełącz przełączniki / tryby"), + ("Track Name:", "Nazwa ścieżki:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Wytrenowane modele są dostępne w aplikacji desktopowej; przeglądarka używa klasycznego silnika.", + ), + ("Transform", "Przekształć"), + ("Transparency grid", "Siatka przezroczystości"), + ("Tutorial complete", "Samouczek ukończony"), + ("Two-Node Camera", "Kamera dwuwęzłowa"), + ("Type", "Typ"), + ("Type a command or effect…", "Wpisz polecenie lub efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odepnij panel"), + ("Move Panel to New Window", "Przenieś panel do nowego okna"), + ("Move Panel to Main Window", "Przenieś panel do głównego okna"), + ("Unfreeze", "Odmroź"), + ("Unlink {}", "Odłącz {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Użyj tej opcji, aby umieścić zaznaczone warstwy razem w nowej kompozycji pośredniej.", + ), + ("VR Environments", "Środowiska VR"), + ("Value:", "Wartość:"), + ("Variable Font Axes", "Osie czcionki zmiennej"), + ("Video Codec: {}", "Kodek wideo: {}"), + ("View:", "Widok:"), + ("Welcome to EffectCraft", "Witaj w EffectCraft"), + ("Width:", "Szerokość:"), + ("You can undo this.", "Można to cofnąć."), + ("You can't undo this.", "Tego nie można cofnąć."), + ("Your changes will be lost if you don't save them.", "Jeśli nie zapiszesz zmian, zostaną utracone."), + ("Zoom", "Powiększenie"), + ("built-in", "wbudowany"), + ("frames per second", "klatek na sekundę"), + ("template name", "nazwa szablonu"), + ("{} contributors · {} commits", "Współtwórcy: {} · commity: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {} MB na klatkę 8 bpc"), + ("{} — coming soon", "{} — wkrótce"), + ("{} — double-click to open, {}-click to reveal", "{} — kliknij dwukrotnie, aby otworzyć, {}-klik, aby pokazać w folderze"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-przeciągnij pinezkę, aby nagrać jej ruch"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} jest używany przez:"), + ("3D Reference Axes", "Osie odniesienia 3D"), + ("Affect all layers that use this solid", "Dotyczy wszystkich warstw używających tej planszy"), + ("Choose grid and guide options", "Wybierz opcje siatki i linii pomocniczych"), + ("Colorize", "Koloryzuj"), + ("Custom", "Niestandardowe"), + ("Custom...", "Niestandardowe..."), + ("Fit", "Dopasuj"), + ("Fit up to 100%", "Dopasuj do 100%"), + ("Full", "Pełna"), + ("Half", "Połowa"), + ("Only this layer uses this solid.", "Tylko ta warstwa używa tej planszy."), + ("Proportional Grid", "Siatka proporcjonalna"), + ("Quarter", "Jedna czwarta"), + ("Region of Interest", "Obszar zainteresowania"), + ("Reset Exposure", "Resetuj ekspozycję"), + ("Show Channel and Color Management Settings", "Pokaż ustawienia kanałów i zarządzania kolorem"), + ("Show Snapshot (hold)", "Pokaż migawkę (przytrzymaj)"), + ("Take Snapshot", "Zrób migawkę"), + ("Third", "Jedna trzecia"), + ("Toggle Mask and Shape Path Visibility", "Przełącz widoczność ścieżek masek i kształtów"), + ("Toggle Transparency Grid", "Przełącz siatkę przezroczystości"), + ("16 bits per channel", "16 bitów na kanał"), + ("32 bits per channel (float)", "32 bity na kanał (zmiennoprzecinkowe)"), + ("8 bits per channel", "8 bitów na kanał"), + ("Adaptive Resolution", "Rozdzielczość adaptacyjna"), + ("Adobe-style built-in", "Wbudowany w stylu Adobe"), + ("Blue", "Niebieski"), + ("Current Settings", "Bieżące ustawienia"), + ("Disable Per-character 3D", "Wyłącz 3D dla każdego znaku"), + ("Draft", "Robocza"), + ("Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("Fast Draft", "Szybka robocza"), + ("Fast Previews (Draft 3D, Faster Effects)", "Szybkie podglądy (robocze 3D, szybsze efekty)"), + ("Green", "Zielony"), + ("Import Image Sequence", "Importuj sekwencję obrazów"), + ("Import PDF / Illustrator File", "Importuj plik PDF / Illustrator"), + ("Import Photoshop File", "Importuj plik Photoshopa"), + ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), + ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), + ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), + ("Mercury Software Only", "Tylko programowy Mercury"), + ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), + ("Off (Final Quality)", "Wył. (jakość końcowa)"), + ("OpenGL (compatibility)", "OpenGL (zgodność)"), + ("Opus Bitrate: {} kbps", "Przepływność Opus: {} kb/s"), + ("RGB Straight", "RGB proste"), + ("Red", "Czerwony"), + ("Tone map", "Mapowanie tonów"), + ("Use In-Window Menu Bar on macOS", "Używaj paska menu w oknie w macOS"), + ("Wireframe", "Szkielet"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (domyślny)"), + ("New Project", "Nowy projekt"), + ("Open Project…", "Otwórz projekt…"), + ("Templates", "Szablony"), + ("EffectCraft home page", "Strona główna EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("Motion graphics and visual effects · version {}", "Grafika ruchoma i efekty wizualne · wersja {}"), + ("New Composition", "Nowa kompozycja"), + ("Open Demo Project", "Otwórz projekt demonstracyjny"), + ("Import Footage…", "Importuj materiał…"), + ("Filter recent files", "Filtruj ostatnie pliki"), + ("NAME", "NAZWA"), + ("OPENED", "OTWARTY"), + ("SIZE", "ROZMIAR"), + ("KIND", "RODZAJ"), + ("Your recent files will show here", "Tu pojawią się ostatnie pliki"), + ("Create a new project, or open the demo project to look around.", "Utwórz nowy projekt albo otwórz projekt demonstracyjny, aby się rozejrzeć."), + ("No recent files match the filter", "Żaden ostatni plik nie pasuje do filtra"), + ("Try another name or folder.", "Spróbuj innej nazwy lub folderu."), + ("Just now", "Przed chwilą"), + ("Yesterday", "Wczoraj"), + ("{} minute ago", "{} minutę temu"), + ("{} minutes ago", "{} min temu"), + ("{} hour ago", "{} godzinę temu"), + ("{} hours ago", "{} godz. temu"), + ("{} day ago", "{} dzień temu"), + ("{} days ago", "{} dni temu"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animacja Lottie"), + ("Project", "Projekt"), + ("General", "Ogólne"), + ("Language", "Język"), + ("Levels of Undo", "Poziomy cofania"), + ("Path Point and Handle Size", "Rozmiar punktów i uchwytów ścieżki"), + ("Recent Projects Shown", "Liczba wyświetlanych ostatnich projektów"), + ("Options", "Opcje"), + ("Show Tool Tips", "Pokazuj podpowiedzi"), + ("Create Layers at Composition Start Time", "Twórz warstwy w czasie początku kompozycji"), + ("Switches Affect Nested Comps", "Przełączniki wpływają na zagnieżdżone kompozycje"), + ("Default Spatial Interpolation to Linear", "Domyślna interpolacja przestrzenna liniowa"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Zachowaj stałą liczbę wierzchołków i punktów wtapiania podczas edycji masek"), + ("Synchronize Time of All Related Items", "Synchronizuj czas wszystkich powiązanych elementów"), + ("Expression Pick Whip Writes Compact English", "Łącznik wyrażeń zapisuje zwięzły angielski"), + ("Create Split Layers Above Original Layer", "Twórz podzielone warstwy nad oryginalną warstwą"), + ("Use System Color Picker", "Używaj systemowego próbnika kolorów"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft zawsze używa własnego próbnika kolorów (takiego samego na każdej platformie i w przeglądarce).", + ), + ("Startup & Repair", "Uruchamianie i naprawa"), + ("Show Home Screen When Launching", "Pokazuj ekran startowy przy uruchamianiu"), + ("Show Home Screen When Opening a Project", "Pokazuj ekran startowy przy otwieraniu projektu"), + ("Offer to Open the Latest Auto-Save After a Crash", "Proponuj otwarcie ostatniego autozapisu po awarii"), + ("Window Graphics", "Grafika okna"), + ("Automatic", "Automatycznie"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Grafika okna obowiązuje od następnego uruchomienia. Gdy sterownik graficzny zatrzyma EffectCraft przy otwieraniu okna, następne uruchomienie przełączy się na OpenGL.", + ), + ("Repair", "Naprawa"), + ("Reset Settings", "Resetuj ustawienia"), + ("Reset Keyboard Shortcuts", "Resetuj skróty klawiaturowe"), + ("Empty Disk Cache", "Opróżnij pamięć dyskową"), + ("New Project Loads Template", "Nowy projekt wczytuje szablon"), + ("Template Project", "Projekt szablonu"), + ("Auto-Save", "Autozapis"), + ("Automatically Save Projects", "Automatycznie zapisuj projekty"), + ("Save Every", "Zapisuj co"), + ("Minutes", "min"), + ("Maximum Project Versions", "Maksymalna liczba wersji projektu"), + ("Auto-Save Location", "Lokalizacja autozapisu"), + ("Next to Project", "Obok projektu"), + ("Custom Location", "Lokalizacja niestandardowa"), + ("Save When Starting Render Queue", "Zapisuj przy uruchamianiu kolejki renderowania"), + ("Save Now", "Zapisz teraz"), + ("Motion Path", "Ścieżka ruchu"), + ("All Keyframes", "Wszystkie klatki kluczowe"), + ("No Motion Path", "Bez ścieżki ruchu"), + ("No More Than (Seconds)", "Nie więcej niż (sekundy)"), + ("No More Than (Keyframes)", "Nie więcej niż (klatki kluczowe)"), + ("Seconds", "s"), + ("Keyframes", "Klatki kluczowe"), + ("Display", "Wyświetlanie"), + ("Show Rendering Progress in Info Panel and Flowchart", "Pokazuj postęp renderowania w panelu Informacje i schemacie"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Akceleracja sprzętowa paneli Kompozycja, Warstwa i Materiał"), + ("Panels are always drawn by the GPU (wgpu).", "Panele są zawsze rysowane przez GPU (wgpu)."), + ("Previews", "Podglądy"), + ("Adaptive Resolution Limit", "Limit rozdzielczości adaptacyjnej"), + ("Show Internal Wireframes", "Pokazuj wewnętrzne szkielety"), + ("Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("Viewer Zoom Quality", "Jakość powiększenia przeglądarki"), + ("Faster", "Szybciej"), + ("More Accurate", "Dokładniej"), + ("Display Color Space", "Przestrzeń kolorów wyświetlacza"), + ("GPU Information...", "Informacje o GPU..."), + ("Appearance", "Wygląd"), + ("Brightness", "Jasność"), + ("UI Scale", "Skala interfejsu"), + ("Labels and Colors", "Etykiety i kolory"), + ("Use Label Color for Layer Handles and Paths", "Używaj koloru etykiety dla uchwytów i ścieżek warstw"), + ("Use Label Color for Related Tabs", "Używaj koloru etykiety dla powiązanych kart"), + ("Cycle Mask Colors", "Zmieniaj kolejno kolory masek"), + ("Use Gradients", "Używaj gradientów"), + ("Menu Bar", "Pasek menu"), + ("Grids & Guides", "Siatki i linie pomocnicze"), + ("Gridline Every", "Linia siatki co"), + ("Subdivisions", "Podpodziały"), + ("Style", "Styl"), + ("Lines", "Linie"), + ("Dashed Lines", "Linie przerywane"), + ("Dots", "Kropki"), + ("Horizontal", "Poziomo"), + ("Vertical", "Pionowo"), + ("Safe Margins", "Marginesy bezpieczne"), + ("Action-safe", "Bezpieczny dla akcji"), + ("Title-safe", "Bezpieczny dla tytułów"), + ("Labels", "Etykiety"), + ("Text Engine", "Silnik tekstu"), + ("Latin", "Łaciński"), + ("South Asian and Middle Eastern", "Południowoazjatycki i bliskowschodni"), + ("Show Font Preview", "Pokazuj podgląd czcionki"), + ("Number of Recent Fonts to Display", "Liczba wyświetlanych ostatnich czcionek"), + ("Show Font Names in English", "Pokazuj nazwy czcionek po angielsku"), + ("Still Footage", "Materiał nieruchomy"), + ("Length of Composition", "Długość kompozycji"), + ("Duration (Seconds)", "Czas trwania (sekundy)"), + ("Still Duration", "Czas trwania obrazu"), + ("Sequence Footage", "Materiał sekwencyjny"), + ("Report Missing Frames", "Zgłaszaj brakujące klatki"), + ("Automatically Reload Footage", "Automatycznie wczytuj ponownie materiał"), + ("Off", "Wył."), + ("Non-Sequence Footage", "Materiał niesekwencyjny"), + ("All Footage", "Wszystkie materiały"), + ("Ask User", "Pytaj użytkownika"), + ("Guess", "Zgadnij"), + ("Ignore", "Ignoruj"), + ("Straight", "Prosta"), + ("Premultiplied", "Wstępnie mnożona"), + ("Default Drag Import As", "Domyślny import przeciągnięciem jako"), + ("Footage", "Materiał"), + ("Composition - Retain Layer Sizes", "Kompozycja – zachowaj rozmiary warstw"), + ("Export", "Eksport"), + ("Default Output Folder", "Domyślny folder wyjściowy"), + ("Segment Sequences", "Dziel sekwencje na segmenty"), + ("Files per Segment", "Pliki na segment"), + ("files", "pliki"), + ("Segment Movie Files", "Dziel pliki filmowe na segmenty"), + ("Segment Size", "Rozmiar segmentu"), + ("Append Bit Depth to File Name", "Dodawaj głębię bitową do nazwy pliku"), + ("Audio", "Audio"), + ("Audio Hardware", "Sprzęt audio"), + ("Preview Sample Rate", "Częstotliwość próbkowania podglądu"), + ("Audio Output Mapping", "Mapowanie wyjść audio"), + ("Left", "Lewy"), + ("Right", "Prawy"), + ("channel", "kanał"), + ("Disk", "Dysk"), + ("Disk Cache", "Pamięć dyskowa"), + ("Enable Disk Cache", "Włącz pamięć dyskową"), + ("Maximum Disk Cache Size", "Maksymalny rozmiar pamięci dyskowej"), + ("Disk Cache Folder", "Folder pamięci dyskowej"), + ("Media Cache", "Pamięć multimediów"), + ("Database and Cache Folder", "Folder bazy danych i pamięci"), + ("Conformed Audio Folder", "Folder dopasowanego audio"), + ("Memory & CPU", "Pamięć i procesor"), + ("RAM Reserved for Other Applications", "Pamięć RAM zarezerwowana dla innych aplikacji"), + ("Cache Budgets", "Budżety pamięci podręcznej"), + ("Layer Cache", "Pamięć warstw"), + ("Footage Frame Cache", "Pamięć klatek materiałów"), + ("Preview (RAM) Cache", "Pamięć podglądu (RAM)"), + ("Reduce Cache Size When System Is Low on Memory", "Zmniejsz pamięć podręczną, gdy w systemie brakuje pamięci"), + ("Video", "Wideo"), + ("Enable Video Preview Output", "Włącz wyjście podglądu wideo"), + ("Video Device", "Urządzenie wideo"), + ("Floating Window", "Okno pływające"), + ("Full Screen", "Pełny ekran"), + ("Video Output During Playback", "Wyjście wideo podczas odtwarzania"), + ("Mirror on Computer Monitor", "Powielaj na monitorze komputera"), + ("Disable Video Output When in Background", "Wyłącz wyjście wideo w tle"), + ("Default 3D Renderer", "Domyślny renderer 3D"), + ("Show 3D Reference Axes", "Pokazuj osie odniesienia 3D"), + ("Extended Viewer", "Rozszerzona przeglądarka"), + ("Realtime Shadows in Draft", "Cienie w czasie rzeczywistym w trybie roboczym"), + ("Scripting & Expressions", "Skrypty i wyrażenia"), + ("Application Scripting", "Skrypty aplikacji"), + ("Allow Scripts to Write Files and Access Network", "Zezwalaj skryptom na zapis plików i dostęp do sieci"), + ("Warn User When Executing Files", "Ostrzegaj przy uruchamianiu plików"), + ("Enable JavaScript Debugger", "Włącz debuger JavaScript"), + ("Expressions Editor", "Edytor wyrażeń"), + ("Font Size", "Rozmiar czcionki"), + ("Syntax Highlighting", "Podświetlanie składni"), + ("Line Numbers", "Numery wierszy"), + ("Auto-complete", "Autouzupełnianie"), + ("Bracket Matching", "Dopasowywanie nawiasów"), + ("Word Wrap", "Zawijanie wierszy"), + ("Show Expression Error Banner", "Pokazuj pasek błędów wyrażeń"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "Wyrażenia używają silnika JavaScript. Błędy skryptów podają plik i wiersz w konsoli skryptów; nie ma debugera krokowego.", + ), + ("Roto Brush", "Pędzel Roto"), + ("Segmentation Model", "Model segmentacji"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Pędzel Roto 2.0 i 3.0 używa wybranego tu modelu (wersja 1.0 zawsze używa klasycznego silnika). Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Face Tracking", "Śledzenie twarzy"), + ("Face Model", "Model twarzy"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Śledzenie twarzy w panelu Śledzenie (tylko kontur i szczegółowe cechy) używa wybranego tu modelu. Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Match System", "Zgodnie z systemem"), + ("Default", "Domyślny"), + ("Stored, but not used by EffectCraft yet", "Zapisane, ale jeszcze nieużywane przez EffectCraft"), + ("Total Offset", "Całkowite przesunięcie"), + ("Per Layer", "Na warstwę"), + ("Track", "Ścieżka"), + ("Copy", "Kopiuj"), + ("Paste", "Wklej"), + ("Edit Value…", "Edytuj wartość…"), + ("Remove Expression", "Usuń wyrażenie"), + ("Effects", "Efekty"), + ("Copy Source Path", "Kopiuj ścieżkę źródła"), + ("Resolution:", "Rozdzielczość:"), + ("Guide Layer", "Warstwa pomocnicza"), + ("Layer Styles", "Style warstwy"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -3607,6 +5713,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3657,6 +5764,7 @@ const CZECH: &[(&str, &str)] = &[ ("Overlay", "Překrytí"), ("Parameter Name:", "Název parametru:"), ("Parent pick whip: drag onto a layer", "Propojovací nástroj nadřazené vrstvy: přetáhněte na vrstvu"), + ("Track Matte pick whip: drag onto a layer", "Propojovací nástroj masky stopy: přetáhněte na vrstvu"), ("Pixel Aspect Ratio:", "Poměr stran pixelů:"), ("Playing on Video Preview", "Přehrávání ve videonáhledu"), ("Post-Render Action: {}", "Akce po vykreslení: {}"), @@ -4191,6 +6299,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), @@ -4221,6 +6330,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Overlay", "Superposición"), ("Parameter Name:", "Nombre del parámetro:"), ("Parent pick whip: drag onto a layer", "Selector de capa padre: arrastra hasta una capa"), + ("Track Matte pick whip: drag onto a layer", "Selector de mate de seguimiento: arrastra hasta una capa"), ("Pixel Aspect Ratio:", "Relación de aspecto de píxel:"), ("Playing on Video Preview", "Reproduciendo en la previsualización de vídeo"), ("Post-Render Action: {}", "Acción posterior al procesamiento: {}"), @@ -4985,9 +7095,9 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Import Image Sequence", "გამოსახულებების თანმიმდევრობის იმპორტი"), ("Import PDF / Illustrator File", "PDF / Illustrator ფაილის იმპორტი"), ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), - ("Interpret Unlabeled Alpha As", "არამონიშნული ალფას ინტერპრეტაცია როგორც"), - ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), - ("Mercury GPU Acceleration ({})", "Mercury GPU აჩქარება ({})"), + ("Interpret Unlabeled Alpha As", "უცნობი ალფას ინტერპრეტაცია"), + ("Join the ArtCraft Discord", "შემოგვიერთდით Discord-ზე"), + ("Mercury GPU Acceleration ({})", "GPU აჩქარება ({})"), ("Mercury Software Only", "მხოლოდ Mercury Software"), ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), @@ -5006,7 +7116,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("EffectCraft on GitHub", "EffectCraft GitHub-ზე"), ("Motion graphics and visual effects · version {}", "მოძრავი გრაფიკა და ვიზუალური ეფექტები · ვერსია {}"), ("New Composition", "ახალი კომპოზიცია"), - ("Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), + ("Open Demo Project", "დემო-პროექტის გახსნა"), ("Import Footage…", "ვიდეომასალის იმპორტი…"), ("Filter recent files", "ბოლო ფაილების გაფილტვრა"), ("NAME", "სახელი"), @@ -5032,7 +7142,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("General", "ზოგადი"), ("Language", "ენა"), ("Levels of Undo", "დაბრუნების დონეები"), - ("Path Point and Handle Size", "ტრაექტორიის წერტილისა და სახელურის ზომა"), + ("Path Point and Handle Size", "წერტილისა და სახელურის ზომა"), ("Recent Projects Shown", "ნაჩვენები ბოლო პროექტები"), ("Options", "პარამეტრები"), ("Show Tool Tips", "მინიშნებების ჩვენება"), @@ -5071,7 +7181,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Automatically Save Projects", "პროექტების ავტომატური შენახვა"), ("Save Every", "შენახვა ყოველ"), ("Minutes", "წუთში"), - ("Maximum Project Versions", "პროექტის ვერსიების მაქსიმალური რაოდენობა"), + ("Maximum Project Versions", "ვერსიების მაქსიმალური რაოდენობა"), ("Auto-Save Location", "ავტოშენახვის მდებარეობა"), ("Next to Project", "პროექტის გვერდით"), ("Custom Location", "სხვა მდებარეობა"), @@ -5092,7 +7202,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Adaptive Resolution Limit", "ადაპტური გარჩევადობის ზღვარი"), ("Show Internal Wireframes", "შიდა კარკასების ჩვენება"), ("Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), - ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბირების ხარისხი"), + ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბის ხარისხი"), ("Faster", "უფრო სწრაფი"), ("More Accurate", "უფრო ზუსტი"), ("Display Color Space", "ეკრანის ფერთა სივრცე"), @@ -5123,7 +7233,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Latin", "ლათინური"), ("South Asian and Middle Eastern", "სამხრეთაზიური და ახლო აღმოსავლური"), ("Show Font Preview", "შრიფტის წინასწარი ნახვის ჩვენება"), - ("Number of Recent Fonts to Display", "საჩვენებელი ბოლო შრიფტების რაოდენობა"), + ("Number of Recent Fonts to Display", "ბოლო შრიფტების რაოდენობა"), ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), ("Still Footage", "უძრავი ვიდეომასალა"), ("Length of Composition", "კომპოზიციის სიგრძე"), @@ -5131,7 +7241,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Still Duration", "უძრავი კადრის ხანგრძლივობა"), ("Sequence Footage", "სეკვენციური ვიდეომასალა"), ("Report Missing Frames", "გამოტოვებული კადრების შეტყობინება"), - ("Automatically Reload Footage", "ვიდეომასალის ავტომატური ხელახლა ჩატვირთვა"), + ("Automatically Reload Footage", "ავტომატური ხელახლა ჩატვირთვა"), ("Off", "გამორთული"), ("Non-Sequence Footage", "არასეკვენციური ვიდეომასალა"), ("All Footage", "მთელი ვიდეომასალა"), @@ -5140,7 +7250,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Ignore", "უგულებელყოფა"), ("Straight", "პირდაპირი"), ("Premultiplied", "წინასწარ გამრავლებული"), - ("Default Drag Import As", "გადათრევით იმპორტი ნაგულისხმევად როგორც"), + ("Default Drag Import As", "გადათრევით იმპორტი როგორც"), ("Footage", "ვიდეომასალა"), ("Composition - Retain Layer Sizes", "კომპოზიცია - შრეების ზომების შენარჩუნებით"), ("Export", "ექსპორტი"), @@ -5153,7 +7263,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Append Bit Depth to File Name", "ბიტური სიღრმის ფაილის სახელზე დამატება"), ("Audio", "აუდიო"), ("Audio Hardware", "აუდიოაპარატურა"), - ("Preview Sample Rate", "წინასწარი ნახვის დისკრეტიზაციის სიხშირე"), + ("Preview Sample Rate", "ნახვის დისკრეტიზაციის სიხშირე"), ("Audio Output Mapping", "აუდიოს გამოტანის ასახვა"), ("Left", "მარცხენა"), ("Right", "მარჯვენა"), @@ -5228,7 +7338,7 @@ const GEORGIAN: &[(&str, &str)] = &[ /// exactly the Chinese catalogs' sources; the tests hold these to the same rules. const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Search project", "პროექტში ძიება"), - ("Select an item to see its details", "მონიშნეთ ელემენტი მისი დეტალების სანახავად"), + ("Select an item to see its details", "მონიშნეთ ელემენტი დეტალებისთვის"), ("Media Duration", "მედიის ხანგრძლივობა"), ("Frame Rate", "კადრების სიხშირე"), ("File Path", "ფაილის მისამართი"), @@ -5382,12 +7492,12 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Current Frame", "მიმდინარე კადრი"), ("Work Area", "სამუშაო არე"), ("Luminance", "სიკაშკაშე"), - ("Create Markers", "მარკერების შექმნა"), + ("Create Markers", "მარკერები"), ("Split Layers", "შრეების დაყოფა"), ("Split and Precompose", "დაყოფა და კომპოზიციაში მოთავსება"), ("CMX 3600 EDL (.edl, V1 only)", "CMX 3600 EDL (.edl, მხოლოდ V1)"), ("OMF (.omf, audio)", "OMF (.omf, აუდიო)"), - ("Layers Premiere can't show (text, shapes, effects…)", "შრეები, რომლებსაც Premiere ვერ აჩვენებს (ტექსტი, ფიგურები, ეფექტები…)"), + ("Layers Premiere can't show (text, shapes, effects…)", "Premiere-ში უხილავი შრეები"), ("All layers", "ყველა შრე"), ("None (leave them out)", "არცერთი (გამოტოვება)"), ("Nested sequences", "ჩადგმული სეკვენციები"), @@ -5447,6 +7557,7 @@ mod tests { ("menus/frontend.rs", include_str!("../menus/frontend.rs")), ("menus/invoke.rs", include_str!("../menus/invoke.rs")), ("menus/shortcuts.rs", include_str!("../menus/shortcuts.rs")), + ("state.rs", include_str!("../state.rs")), ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), @@ -5513,6 +7624,8 @@ mod tests { ("engine settings choices", include_str!("../../../engine/src/settings/prefs/choices.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/cmd/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; fn catalogs() -> Vec<(&'static str, &'static str)> { @@ -5524,6 +7637,55 @@ mod tests { rows.iter().find(|(source, _)| *source == en).map_or(en, |(_, translated)| *translated) } + #[test] + fn the_toolbar_asks_the_catalog_for_the_tool_names() { + let header = include_str!("../header.rs"); + for call in ["crate::i18n::tr(tool.label())", "crate::i18n::tr(cur.label())"] { + assert!(header.contains(call), "header.rs no longer draws {call}"); + } + assert!( + header.contains("app.auto.add(&format!(\"tools.select.{tool:?}\"), row.rect, tool.label())"), + "the automation label must stay the English tool name" + ); + } + + #[test] + fn the_preview_panel_labels_are_translated() { + // The Preview panel draws these from a local closure, a check table and a roll tuple, none of + // which the call-shape scan sees; they had no rows at all. + for key in [ + "Shortcut", + "Range", + "Play From", + "Frame Rate", + "Resolution", + "Overlays", + "Layer Controls", + "Loop", + "Cache Before Playback", + "Pre-roll", + "Post-roll", + "If caching, play cached frames", + ] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has no {name} row"); + } + } + // A row is not a translation until the panel asks for it: the labels drawn one by one carry + // `tr(..)`, and the table and the roll tuple wrap the text they read at the call. + let source = include_str!("../panels/misc.rs"); + for literal in ["Shortcut", "Range", "Play From", "Frame Rate", "Resolution", "Full Screen", "If caching, play cached frames"] { + assert!(source.contains(&format!("tr(\"{literal}\")")), "{literal} is drawn without asking the catalog"); + } + for call in ["check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on)", "Align2::LEFT_CENTER, tr(text),"] { + assert!(source.contains(call), "{call} no longer asks the catalog"); + } + for table in ["\"Overlays\"", "\"Layer Controls\"", "\"Loop\"", "\"Cache Before Playback\"", "\"Pre-roll\"", "\"Post-roll\""] { + assert!(source.contains(table), "{table} is not in the panel"); + } + } + /// A row that translates nothing, repeats a source string, or changes the number of values the /// English source carries is a bug in the catalog itself. #[test] @@ -5583,10 +7745,321 @@ mod tests { assert_eq!(tr_args("Proxy Use: {}", &[]), "Proxy Use: "); } + /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a + /// row (a layer or file name) stays verbatim. + #[test] + fn japanese_rows_translate_the_panels() { + set_current("ja"); + assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); + set_current("en"); + } + /// Every row is asked for by a converted panel: a leftover row is stale, because the panel's /// English source changed or went away. A string a helper draws for the panel (a toolbar /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English /// source only appears as a literal there. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. A call split over + /// several lines is still one argument list, so this walks past newlines until the + /// parenthesis closes. It understands the two string forms a label can be written in - a + /// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis + /// inside either is not mistaken for code. + fn args_of(s: &str) -> Vec<&str> { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + } + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len()); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (i, c) = cs[k]; + match mode { + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + mode = Mode::Code; + k += n; + } + } + } + Mode::Str(escaped) => { + mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) }; + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + if start.is_none() { + start = Some(i); + } + } + 'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + mode = Mode::Raw(n); + start = Some(i); + k += n + 1; + } + } + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + end = i; + break; + } + depth -= 1; + } + ',' if depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + }, + } + k += 1; + } + if let Some(st) = start { + out.push(s[st..end].trim()); + } + out + } + + /// The text a literal argument draws: a plain string with its escapes undone, or a raw + /// string with none. `None` when the argument is not one literal (a concatenation, a call). + fn literal_text(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('r') { + let hashes = rest.chars().take_while(|c| *c == '#').count(); + // An identifier that starts with `r` (`result`) is not a raw string: the quote has + // to follow the `#`s. + if rest.chars().nth(hashes) != Some('"') { + return None; + } + let close = format!("\"{}", "#".repeat(hashes)); + return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string); + } + let body = arg.strip_prefix('"')?.strip_suffix('"')?; + // Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is + // one character (`»`), not six. + let mut out = String::new(); + let mut it = body.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + if c == '"' { + return None; // two literals side by side + } + out.push(c); + continue; + } + match it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'x' => { + let hex: String = it.by_ref().take(2).collect(); + let byte = u8::from_str_radix(&hex, 16).ok()?; + out.push(char::from(byte)); + } + 'u' => { + if it.next() != Some('{') { + return None; + } + let hex: String = it.by_ref().take_while(|c| *c != '}').collect(); + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => { + out.push('\\'); + out.push(other); + } + } + } + Some(out) + } + + /// `source` with comments blanked and the *contents* of every string literal blanked too, + /// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a + /// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not + /// mistaken for a call; the argument list is then read from the original at that offset. + fn code_only(source: &str) -> String { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + Comment, + } + let cs: Vec<(usize, char)> = source.char_indices().collect(); + // Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so + // every offset found here indexes the original source as well. + let mut out: Vec = Vec::with_capacity(source.len()); + let keep = |out: &mut Vec, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); + let blank = |out: &mut Vec, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8())); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (_, c) = cs[k]; + match mode { + Mode::Comment => { + if c == '\n' { + mode = Mode::Code; + keep(&mut out, c); + } else { + blank(&mut out, c); + } + } + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + mode = Mode::Code; + k += n; + } else { + blank(&mut out, c); + } + } else { + blank(&mut out, c); + } + } + Mode::Str(escaped) => { + if c == '"' && !escaped { + mode = Mode::Code; + keep(&mut out, c); + } else { + mode = Mode::Str(c == '\\' && !escaped); + blank(&mut out, c); + } + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + keep(&mut out, c); + } + '/' if out.last() == Some(&b'/') => { + // The first `/` stays; this one becomes a space. Both are one byte, so + // the mask keeps its length and every offset still indexes the source. + blank(&mut out, c); + mode = Mode::Comment; + } + 'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + keep(&mut out, '"'); + mode = Mode::Raw(n); + k += n + 1; + } else { + keep(&mut out, c); + } + } + _ => keep(&mut out, c), + }, + } + k += 1; + } + debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset"); + String::from_utf8(out).expect("mask keeps UTF-8 boundaries") + } + + for (path, source) in CONVERTED { + // The test module draws nothing; it holds sample strings. A test-only helper above it + // (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range, + // because production code follows it. + let cut = source + .match_indices("#[cfg(test)]") + .find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod "))) + .map_or(source.len(), |(at, _)| at); + let source = &source[..cut]; + let code = code_only(source); + let line_of = |at: usize| code[..at].matches('\n').count() + 1; + let check = |at: usize, arg: &str, what: &str| { + // A single character is a symbol or a glyph, not interface text - the same exemption + // the call-shape scan above makes. A call that already asks the catalog is not a + // literal here at all. + if let Some(text) = literal_text(arg) + && text.chars().count() >= 2 + && !VERBATIM.contains(&text.as_str()) + { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at)); + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + for (at, _) in code.match_indices(&marker) { + if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) { + check(at, arg, &format!("widgets::{call}")); + } + } + } + for (at, _) in code.match_indices(".text(") { + if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) { + check(at, arg, "Painter::text"); + } + } + } + } + + #[test] + fn the_composition_settings_labels_are_translated() { + // The Composition Settings dialog (and New Composition) draws these from a `(label, value)` + // table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They + // used to have no row in any catalog, so every interface drew them in English. + for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row"); + } + } + } + #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 949ea3771..385a6b1ca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -48,6 +48,8 @@ pub enum Icon { Speaker, Solo, Lock, + /// An open padlock: the shackle lifted clear of the body on the right. + LockOpen, Shy, Collapse, Quality, @@ -402,6 +404,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(8.0, 7.0, 3.0, 180.0, 360.0); pen.dot(8.0, 10.5, 1.0); } + LockOpen => { + pen.rect(3.5, 7.0, 12.5, 14.0); + pen.arc(8.0, 4.0, 3.0, 180.0, 360.0); + pen.line(&[(5.0, 4.0), (5.0, 7.0)]); + pen.dot(8.0, 10.5, 1.0); + } Shy => { pen.circle(8.0, 8.0, 5.8); pen.dot(6.0, 7.0, 0.8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcbfd49bf..27e24d4da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ mod app; mod clock; mod hooks; +pub use app::{GPU_PREPARING, GpuBuild}; #[cfg(test)] use app::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; pub use clock::AppClock; @@ -184,6 +185,17 @@ pub struct Playback { /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, + /// Starting a preview with sound (#947): the playhead stays on the first frame while the + /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then + /// sound and picture start together from it. Without this the picture played on silently + /// while they rendered and the sound started at the later frame, so the opening was lost. + /// A stall later on still falls back to [`Playback::audio_held`]. + pub starting: bool, + /// When the preview started (seconds): [`Playback::starting`] gives up after `STARTUP_LIMIT`. + pub starting_since: f64, + /// When the first frame was cached while [`Playback::starting`] (seconds): the wait for the + /// frames ahead gives up `STARTUP_WAIT` after it. + pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } @@ -299,8 +311,17 @@ pub struct EffectcraftApp { /// The GPU compositor on that device (None: no usable adapter → CPU only). pub(crate) gpu: Option, gpu_checked: bool, + /// The GPU compositor being built on a worker thread (desktop): the window opens at once and + /// composites on the CPU until the worker's result lands ([`Self::init_gpu`]). + #[cfg(not(target_arch = "wasm32"))] + gpu_build: Option>, gpu_failures: Option, gpu_failure_applied: Option, + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage + /// files, started with the first project that has items ([`Self::tick_footage_reload`]). + footage_watcher: Option, + /// The project revision and preference the watched set was last computed for. + footage_watch_key: Option<(u64, String)>, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 0610233de..ec9ab9170 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -14,6 +14,12 @@ fn browse_menu_id() -> egui::Id { egui::Id::unique("menu-bar-browse") } +/// The id under which [`menu_bar`] remembers where it last saw the pointer (`None`: outside the +/// window). +fn last_pointer_id() -> egui::Id { + egui::Id::unique("menu-bar-last-pointer") +} + /// Draw the in-window menu bar (the engine's AE menu tree). /// /// The titles open on the press, not the release (native menu bars): while one is held the menus @@ -34,6 +40,12 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) { nav.set_browsing(); } + // Whether the pointer has moved since the last frame. Kept by position rather than egui's + // `delta`, which is zero for the first position after the pointer comes back into the window: + // coming back in over another title must switch to it too (#1290). + let pointer = ctx.pointer_hover_pos(); + let last = ctx.data_mut(|d| std::mem::replace(d.get_temp_mut_or(last_pointer_id(), None), pointer)); + let moved = pointer.is_some() && pointer != last; let mut drawn = 0; ui.horizontal_centered(|ui| { ui.add_space(6.0); @@ -53,7 +65,6 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { // then happened somewhere else), and toggle the menu. Hovering another title // switches to it once the pointer has moved: merely resting there must not take a // keyboard-driven menu over. - let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); // With the button down elsewhere nothing is hovered, so the title under the // pointer takes the highlight by hand: the hover-switch to it shows, one frame // later like egui's own hover (the open title keeps its open look). @@ -149,13 +160,18 @@ fn menu_nodes( // saved workspaces. let ws = app.ui.workspace.clone(); let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved)); + if nodes.is_empty() && let Some(e) = empty { ui.add_enabled(false, egui::Button::new((gutter(false), e))); } - for (i, e) in entries.iter().enumerate() { + for (i, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth); + continue; + }; let tip = match name.as_str() { "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(), @@ -163,7 +179,12 @@ fn menu_nodes( "recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 8fee44a72..d7b5d56ca 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -119,7 +119,8 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { } } if e.command == "edit.clear" && app.ui.focused == PanelKind::Project { - return app.session.is_enabled("project.delete"); + // An open text edit stands down: Clear must not delete the comp being typed into (#2227). + return app.session.state.text_edit.is_none() && app.session.is_enabled("project.delete"); } if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 047c576a5..1bd11dabe 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -5,10 +5,15 @@ use super::{EffectcraftApp, PanelKind, Resolution, Value, json}; const COPY_COMMANDS: &[&str] = &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; -/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must -/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. -fn clipboard_note(s: &effectcraft_engine::Session) -> String { +/// Text for the system clipboard: copied keyframes and values as After Effects keyframe text, +/// for other applications (#1908), else a line naming what EffectCraft copied. The system +/// clipboard must hold something: with nothing on it, the windowing layer sends no paste event +/// for Ctrl+V. Paste in EffectCraft uses its own clipboard either way. +fn clipboard_text(s: &effectcraft_engine::Session) -> String { let st = &s.state; + if let Some(text) = &st.keyframe_text { + return text.clone(); + } let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); if st.clip_is_keys && !st.key_clipboard.is_empty() { n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") @@ -30,7 +35,7 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str if let Err(e) = &r { app.ui.status = e.clone(); } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { - ctx.copy_text(clipboard_note(&app.session)); + ctx.copy_text(clipboard_text(&app.session)); } let events = app.session.drain_events(); for (index, ev) in events.into_iter().enumerate() { @@ -320,6 +325,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "window.saveWorkspace" => { + crate::workspace_state::ensure_positions_ready(&app.ui)?; let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.store_saved_workspaces()?; @@ -336,6 +342,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu ); return Ok(Value::Null); }; + crate::workspace_state::ensure_positions_ready(&app.ui)?; app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.workspace = name.clone(); app.store_saved_workspaces()?; @@ -359,6 +366,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; let builtin = crate::dock::WORKSPACES.contains(&name.as_str()); if p.get("delete").and_then(Value::as_bool) == Some(true) { + crate::workspace_state::ensure_positions_ready(&app.ui)?; if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } @@ -373,6 +381,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if builtin { return Err(format!("`{name}` is a built-in workspace: save it as a new workspace instead")); } + crate::workspace_state::ensure_positions_ready(&app.ui)?; let dock = app.ui.saved_workspaces.remove(&name).ok_or_else(|| format!("no workspace `{name}`"))?; app.ui.saved_workspaces.insert(new.to_string(), dock); if app.ui.workspace == name { @@ -479,7 +488,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.home_templates = true; Value::Null } - "effect.manage" | "anim.browsePresets" => { + "effect.manage" => { + app.show_panel(PanelKind::EffectsPresets); + Value::Null + } + "anim.browsePresets" => { + app.ui.effects_search.clear(); + app.ui.effects_view.presets = true; + app.ui.effects_open.insert("presets".into()); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(crate::panels::effects_presets::REVEAL_PRESETS_ID), true)); app.show_panel(PanelKind::EffectsPresets); Value::Null } @@ -529,6 +546,8 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> OpenProject, Save(&'static str), Open(&'static [&'static str]), + /// A movie, or a file with one of these extensions. + OpenFootage(&'static [&'static str]), } let (key, ask) = match id { "file.import" | "file.importMultiple" => ("paths", Ask::Import), @@ -543,6 +562,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "view.exportGuides" => ("path", Ask::Save("Guides.json")), "view.importGuides" => ("path", Ask::Open(&["json"])), + "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")), "file.exportLottie" => ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])), @@ -558,9 +578,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => { - ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) - } + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -571,10 +589,12 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> { return None; } + // Importable files besides movies. const MEDIA: &[&str] = &[ - "mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", - "flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", + "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", "flac", "ogg", "opus", "svg", "pdf", "ai", + "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", ]; + let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::>(); // A host whose blocking dialogs are unsafe inside this frame shows the dialog without // blocking — macOS: the nested event loop re-entered winit's handler on a drag over the // window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped, @@ -583,9 +603,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -606,7 +627,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> } Ask::Import => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -616,6 +637,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; f(exts).first().map(|s| json!(s)) } + Ask::OpenFootage(more) => { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b266623f3..23065067f 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; - let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ectx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); @@ -223,8 +232,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); } } - // Clear follows panel focus, including the Edit menu and keyboard shortcut. + // Clear follows panel focus, including the Edit menu and keyboard shortcut. An open text + // edit stands down: the user is working in a composition, and the Project-panel redirect + // would delete it without asking when nothing uses it (#2227). if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { + if app.session.state.text_edit.is_some() { + return Ok(Value::Null); + } // Through `invoke`, so deleting items that compositions use still asks first (M3.16). return invoke(app, ctx, "project.delete", json!({})); } diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index b9990786c..f50fbdb3c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -210,6 +210,9 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -222,10 +225,37 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } } +/// Escape with a dialog open: it is Cancel. Escape while recording a shortcut cancels the +/// recording, not the editor; so does Escape in its Delete Preset prompt. +fn cancel_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { + return; + } + if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + return; + } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + return; + } + // Escape in Settings is Cancel: restore the settings from when it opened. + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() + { + app.session.prefs = p; + app.session.prefs_changed(); + } + crate::panels::live_preview::cancel(app); + app.dialog = None; +} + type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); /// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. @@ -341,23 +371,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { - // Escape while recording a shortcut cancels the recording, not the editor; so does - // Escape in its Delete Preset prompt. - if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { - continue; - } - if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - continue; - } - // Escape in Settings is Cancel: restore the settings from when it opened. - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - app.dialog = None; + cancel_dialog(app, ctx); continue; } if app.dialog.is_some() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index fac8f0d53..7876ec180 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -131,24 +131,35 @@ impl NativeMenu { /// otherwise follows the project clipboard and would be greyed out with nothing copied there, /// while the field can paste. pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { - fn enable(nodes: &mut [NativeNode]) { + fn enable(app: &EffectcraftApp, nodes: &mut [NativeNode]) { for node in nodes { match node { - NativeNode::Item(item) if edits_text(&item.command) => item.enabled = true, - NativeNode::Submenu { children, .. } => enable(children), + NativeNode::Item(item) if edits_text(&item.command) => { + item.enabled = true; + let source = match item.command.as_str() { + "edit.undo" => Some("Undo"), + "edit.redo" => Some("Redo"), + _ => None, + }; + if let Some(source) = source { + item.label = crate::i18n::label(app, &item.command, source).into(); + } + } + NativeNode::Submenu { children, .. } => enable(app, children), _ => {} } } } let mut menu = build(app); if text_field_window(ctx).is_some() { - enable(&mut menu.menus); + enable(app, &mut menu.menus); } menu } /// Build the native menu spec for the app's current state. pub fn build(app: &EffectcraftApp) -> NativeMenu { + /// Append the native nodes of `nodes` to `out`; item ids start with `path`. fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec) { let mut services_added = false; for (i, n) in nodes.iter().enumerate() { @@ -159,8 +170,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { // Recent projects / footage / presets, undo history, shortcut slots, viewers, // saved workspaces. let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); + if nodes.is_empty() && let Some(e) = empty { out.push(NativeNode::Item(NativeItem { @@ -174,14 +185,20 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { checked: None, })); } - for (k, e) in entries.iter().enumerate() { + for (k, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out); + continue; + }; out.push(NativeNode::Item(NativeItem { id: format!("{id}.d{k}"), label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); @@ -227,6 +244,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::polish(app) { + "Usługi" } else if crate::i18n::georgian(app) { "სერვისები" } else { @@ -344,7 +363,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); s.state.open_comps.hash(&mut h); s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); - s.shortcuts().keys.len().hash(&mut h); + // Each binding's keys: a key changed to another one gives the item a new accelerator. + let shortcuts = s.shortcuts(); + for b in &shortcuts.bindables { + b.key.hash(&mut h); + shortcuts.keys.get(&b.key).hash(&mut h); + } s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -562,6 +586,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn polish_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Usługi", "Ukryj EffectCraft", "Ukryj pozostałe", "Pokaż wszystko"]); + } + #[test] fn georgian_translates_macos_predefined_items() { if !cfg!(target_os = "macos") { @@ -706,6 +751,24 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets + /// the new accelerator even though the number of bindings stays the same. + #[test] + fn a_changed_shortcut_changes_the_state_key() { + let mut app = app(); + let script = json!({"name": "Rename Layers.jsx"}); + let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap(); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap(); + app.session.shortcuts_changed(); + let k0 = state_key(&app); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap(); + app.session.shortcuts_changed(); + assert_ne!(state_key(&app), k0); + let spec = build(&app); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8")); + } + /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it. #[test] fn window_menu_entry_toggles_its_panel() { @@ -736,4 +799,45 @@ mod tests { assert!(edits_text("edit.copy") && edits_text("edit.paste")); assert!(!edits_text("layer.newNull")); } + /// #2459: while a text field has the keyboard the Edit menu's Undo and Redo act on the field and + /// read plain "Undo" / "Redo"; the project history labels come back once focus leaves. + #[test] + fn a_focused_text_field_gets_plain_undo_and_redo_labels() { + fn label(menu: &NativeMenu, id: &str) -> String { + menu.items().into_iter().find(|i| i.command == id).map(|i| i.label.to_string()).unwrap_or_default() + } + for (language, undo, redo) in [("en", "Undo", "Redo"), ("ja", "取り消し", "やり直し")] { + let mut app = app(); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + let ctx = egui::Context::default(); + let history = label(&build_now(&app, &ctx), "edit.undo"); + let redo_history = label(&build_now(&app, &ctx), "edit.redo"); + assert!(history.starts_with(undo) && history != undo, "{history}"); + assert!(redo_history.starts_with(redo) && redo_history != redo, "{redo_history}"); + let mut text = String::new(); + let mut id = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = ui.text_edit_singleline(&mut text); + r.request_focus(); + id = Some(r.id); + }); + out.textures_delta.clear(); + } + assert!(text_field_window(&ctx).is_some()); + let typing = build_now(&app, &ctx); + assert_eq!(label(&typing, "edit.undo"), undo); + assert_eq!(label(&typing, "edit.redo"), redo); + assert!(typing.items().into_iter().any(|i| i.command == "edit.undo" && i.enabled)); + assert!(typing.items().into_iter().any(|i| i.command == "edit.redo" && i.enabled)); + ctx.memory_mut(|m| m.surrender_focus(id.unwrap_or(egui::Id::NULL))); + assert!(text_field_window(&ctx).is_none()); + let after = build_now(&app, &ctx); + assert_eq!(label(&after, "edit.undo"), history); + assert_eq!(label(&after, "edit.redo"), redo_history); + } + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a8fd2a54..f6a5b5321 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; @@ -35,6 +36,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub(crate) mod preview_merge; pub mod primitive_dialog; pub mod project; pub mod properties; @@ -175,6 +177,56 @@ pub(crate) fn space_hand(ctx: &egui::Context) -> bool { ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input() } +/// What a scroll gesture over a viewer (Composition, Layer) does this frame. +pub(crate) struct ViewerScroll { + /// Points to pan by: a two-finger trackpad scroll. + pub pan: egui::Vec2, + /// Factor to zoom by around the pointer: a pinch, Ctrl/Cmd+scroll, a mouse wheel, or + /// Alt + a trackpad scroll. + pub zoom: f32, +} + +/// Splits the frame's scroll input into a viewer pan and a zoom: a mouse wheel zooms and a +/// trackpad's two-finger scroll pans. +/// +/// Where the platform names the device (`MouseWheelSource`, macOS), that decides. Elsewhere a +/// wheel scrolls in lines, but in a browser and with some high-resolution wheels it scrolls in +/// points like a trackpad does, so the unit alone doesn't tell them apart. A scroll in points is +/// a trackpad's once it has shown what a wheel can't do: a touch phase, movement on both axes at +/// once, or a pinch. That holds until a wheel scrolls in lines. Until then the vertical part +/// zooms; the horizontal part always pans. +pub(crate) fn viewer_scroll(ctx: &egui::Context) -> ViewerScroll { + let id = egui::Id::unique("viewer-scroll-trackpad"); + // The scroll is smoothed over the frames after its event, so the device is remembered. + let was: bool = ctx.data(|d| d.get_temp(id)).unwrap_or(false); + let trackpad = ctx.input(|i| { + i.events.iter().fold(was, |t, e| match e { + egui::Event::MouseWheel { source: egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum, .. } => true, + egui::Event::MouseWheel { source: egui::MouseWheelSource::Wheel, .. } => false, + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase, modifiers, .. } => { + // Shift turns a wheel's scroll sideways, which is still one axis. + t || *phase == egui::TouchPhase::Start || (delta.x != 0.0 && delta.y != 0.0 && !modifiers.shift) + } + egui::Event::MouseWheel { .. } => false, + egui::Event::Zoom(_) => true, + _ => t, + }) + }); + if trackpad != was { + ctx.data_mut(|d| d.insert_temp(id, trackpad)); + } + let (scroll, pinch, alt) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers.alt)); + let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; + let scroll = if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO }; + if trackpad && !alt { + ViewerScroll { pan: scroll, zoom: pinch } + } else if alt { + ViewerScroll { pan: egui::Vec2::ZERO, zoom: pinch * (scroll.y / 300.0).exp() } + } else { + ViewerScroll { pan: egui::vec2(scroll.x, 0.0), zoom: pinch * (scroll.y / 300.0).exp() } + } +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { PanelKind::Composition => viewers::show(app, ui, 0, rect), @@ -281,7 +333,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index b07da8567..e6b62efb4 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..80932f490 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,10 +426,15 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); + let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0)); + if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy { + actions.push(("camera.exportBlender".into(), me.clone())); + } + app.auto.add(&format!("effectControls.cameraTracker.{}.exportBlender", g.uid), export, "Export Scene to Blender..."); } let status = match app.session.camera_progress() { Some(pr) if running => pr.banner(), @@ -457,4 +462,4 @@ pub fn editor( } /// Height of [`editor`]. -pub const EDITOR_HEIGHT: f32 = 68.0; +pub const EDITOR_HEIGHT: f32 = 100.0; diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 3f7cda26d..01fbc5995 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -246,36 +246,18 @@ fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` -/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. -pub fn show( +/// The controls of one tab. +fn tab_body( ui: &mut egui::Ui, + tab: Tab, d: &mut CompDraft, t: &Tokens, auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, -) -> Option { - let mut out = None; - ui.horizontal(|ui| { - ui.label(tr("Composition Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); - crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); - auto.add("dialog.comp.name", r.rect, "Composition Name"); - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); - auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); - if r.clicked() { - d.tab = tab; - } - } - }); - ui.separator(); +) { let rate = FrameRate::from_f64(d.fps); - match d.tab { + match tab { Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); @@ -327,7 +309,7 @@ pub fn show( let (ow, oh) = (d.width, d.height); let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); - let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); + let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]); let r = ui.checkbox(&mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { @@ -348,11 +330,11 @@ pub fn show( let cur = PIXEL_ASPECTS .iter() .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) - .map(|p| p.0.to_string()) + .map(|p| tr(p.0).to_string()) .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { for (name, v) in PIXEL_ASPECTS { - if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() { d.pixel_aspect = *v; } } @@ -420,7 +402,7 @@ pub fn show( ui.horizontal(|ui| { let r = crate::widgets::srgb_color_button(ui, &mut d.bg); auto.add("dialog.comp.background", r.rect, "Background Color"); - ui.label(color_name(d.bg)); + ui.label(tr(color_name(d.bg))); }); ui.end_row(); }); @@ -485,6 +467,55 @@ pub fn show( }); } } +} + +/// The height `tab` would take at `top`, from laying it out unseen: it follows the font, the +/// language and the scale. Nothing it registers or changes is kept. +fn tab_height(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> f32 { + let rect = egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), 0.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt("comp-tab-measure").invisible()); + tab_body(&mut child, tab, &mut d.clone(), t, &mut Registry::default(), custom, &mut Vec::new()); + child.cursor().top() - top +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, +) -> Option { + let mut out = None; + ui.horizontal(|ui| { + ui.label(tr("Composition Name:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); + auto.add("dialog.comp.name", r.rect, "Composition Name"); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { + let r = ui.selectable_label(d.tab == tab, tr(label)); + auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); + if r.clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + // Every tab takes the height of the tallest one, so Cancel and OK stay put between tabs. + let body_top = ui.cursor().top(); + tab_body(ui, d.tab, d, t, auto, custom, cmds); + let shown = ui.cursor().top() - body_top; + let tallest = [Tab::Basic, Tab::Advanced, Tab::Renderer] + .into_iter() + .filter(|tab| *tab != d.tab) + .fold(shown, |h, tab| h.max(tab_height(ui, tab, d, t, custom, body_top))); + ui.add_space((tallest - shown).max(0.0)); ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -498,6 +529,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f18db14fd..a0c4b7651 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -76,6 +78,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -109,6 +112,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -123,6 +127,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -138,6 +143,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -190,6 +196,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -202,6 +233,11 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT); ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass)); let size = size.min(modal_space(ctx)); @@ -210,10 +246,10 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); @@ -405,9 +441,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, + // so passing the source keeps the dialog in place when the interface language changes. + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -425,11 +467,25 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app, "comp.settings", params.clone()) { + // The Resolution is not part of the command, so the preview never applied it. + if let Some(cid) = app.dialog_state.editing_comp + && let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution) + { + app.ui.status = e.to_string(); + } + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) }; let r = match cid { @@ -444,7 +500,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -501,8 +565,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::unique("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -588,16 +655,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, command, params.clone()) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 0a4636b87..9b3535288 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,12 +44,19 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come -/// from no one place, so they have neither. +/// Whether a light type has Falloff options (distance attenuation): image-based Environment +/// lights and Ambient come from no one place, so they have none. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows +/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an +/// Environment light's shadow comes from its image, not a falloff. +fn casts_shadows(kind: LightKind) -> bool { + kind != LightKind::Ambient +} + fn comp_w(app: &EffectcraftApp) -> f64 { app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) } @@ -92,8 +99,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -142,13 +155,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -160,6 +179,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -168,8 +190,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -222,46 +247,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -306,8 +351,11 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } + } + if casts_shadows(d.kind) { ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows"); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); @@ -318,52 +366,71 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); ui.add_space(14.0); - if falls_off(d.kind) { + if casts_shadows(d.kind) { ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); } - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = d.kind; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if falls_off(kind) { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = d.kind; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if falls_off(kind) { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + } + if casts_shadows(kind) { + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index c95c90c07..bd566cb7f 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui: let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..122a6abf7 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: app.session.time(), + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); @@ -183,7 +192,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bp.text( pos2(body.center().x, body.min.y + 40.0), Align2::CENTER_CENTER, - "No effects. Drag one from Effects & Presets.", + crate::i18n::tr("No effects. Drag one from Effects & Presets."), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 05fea44c9..249c8462b 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -88,6 +88,11 @@ struct PropCx<'a> { } impl PropCx<'_> { + /// The tokens of the value fields: red while an expression drives the parameter (#1306). + fn value_tokens(&self) -> Tokens { + self.t.for_value(self.prop.has_expression()) + } + fn set(&self, actions: &mut Actions, v: serde_json::Value) { let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) @@ -243,8 +248,8 @@ fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + let PropCx { uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens()); app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -257,7 +262,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let PropCx { uid, cy, vx, prop, .. } = *cx; let (min, max, dec) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), _ => (-1e9, 1e9, 1), @@ -273,7 +278,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, - &t, + &cx.value_tokens(), ); app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); if let Some(nv) = nv { @@ -290,6 +295,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; let c = value.components(); let n = shown_dims(layer, prop, value); + let vt = cx.value_tokens(); let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); @@ -310,7 +316,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, - &t, + &vt, ); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); dims.push(vr); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 380d1d869..e140e49e1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -17,6 +17,7 @@ use crate::{EffectcraftApp, widgets}; const ROW_H: f32 = 21.0; const RECENT_MAX: usize = 8; +pub(crate) const REVEAL_PRESETS_ID: &str = "fx-reveal-presets"; /// A user animation preset file. #[derive(Clone, Debug, PartialEq)] @@ -157,6 +158,24 @@ fn folder_row( open } +/// Number of visible rows before Animation Presets in the current panel state. +fn rows_before_presets(favorites: usize, recent: usize, searching: bool, favorites_open: bool, recent_open: bool) -> usize { + let mut rows = 0usize; + if favorites > 0 { + rows = rows.saturating_add(1); + if searching || favorites_open { + rows = rows.saturating_add(favorites); + } + } + if !searching && recent > 0 { + rows = rows.saturating_add(1); + if recent_open { + rows = rows.saturating_add(recent); + } + } + rows +} + /// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge). #[allow(clippy::too_many_arguments)] fn effect_row( @@ -357,14 +376,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); let view = app.ui.effects_view.clone(); let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); let lp = p.with_clip_rect(list); - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); + let mut scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); let query = q.trim().to_lowercase(); let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; @@ -378,6 +397,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Favorites and Recently Used. let favs: Vec<_> = app.session.prefs.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let reveal_presets = ctx.data_mut(|data| data.remove_temp::(egui::Id::unique(REVEAL_PRESETS_ID))).unwrap_or(false); + if reveal_presets { + // These rows come before Animation Presets. Scroll past expanded lists so the section is + // visible even when Favorites or Recently Used is long. + let preceding_rows = + rows_before_presets(favs.len(), recent.len(), searching, app.ui.effects_open.contains("favorites"), app.ui.effects_open.contains("recent")); + scroll.offset = ROW_H * preceding_rows as f32; + y = list.min.y - scroll.offset; + } if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) { let mut favs = favs; favs.sort_by_key(|e| e.name); @@ -385,7 +414,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply); } } - let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) { for e in recent { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 157288b94..6d742dc4e 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[ "ease", "easeIn", "easeOut", + "bezier", "clamp", "random", "gaussRandom", diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index c7019492f..89cc3493e 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 64c2ced12..ef6458475 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e121d5d02..29364275b 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec } /// The Interpret Footage dialog for the selected footage (or its proxy), if there is one. -fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> { +fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec)> { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { (_, Some(px)) if proxy => Some(px.footage.clone()), (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()), @@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + *base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; - let Some(form) = interpret_footage_form(s, proxy) else { return false }; + let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false }; form } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s), @@ -1074,4 +1121,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 4ed96797a..85b6be25f 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -600,7 +600,7 @@ fn colorama_wheel( p.text( pos2(sr.min.x, sr.max.y + 9.0), Align2::LEFT_CENTER, - "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"), Tokens::ui(10.5), t.text_faint, ); @@ -801,19 +801,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index fe10f4ddb..25e3d1d44 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +64,14 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project as the preview left it, to tell edits made since from the preview's. + applied_at: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +234,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -231,6 +248,11 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); + // The preview already set the property to this draft, in one merged undo step: OK keeps it. + if d.applied.as_ref() == Some(&g) { + app.session.history.merge_key = None; + return; + } let Some(tg) = d.target else { return }; if g == d.baseline { return; @@ -241,7 +263,56 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn to_color32(c: [f32; 4]) -> Color32 { +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + // An edit made in the same frame as OK is not in the last preview yet. + sync_preview(app, ctx); + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + preview_merge::rollback(app, cp, d.applied_at.as_ref()); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => { + app.dialog_state.gradient.applied = Some(g); + app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + let at = app.dialog_state.gradient.applied_at.take(); + preview_merge::rollback(app, cp, at.as_ref()); + app.dialog_state.gradient.applied = None; + } +} + +pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) } @@ -258,7 +329,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -416,6 +488,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -427,12 +503,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 24df22616..35a745c81 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -48,9 +48,21 @@ struct KeyPt { t: f64, v: f64, sel: bool, + muted: bool, handles: Vec, } +/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. +fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { + let fill = match (k.muted, k.sel) { + (true, true) => t.keyframe_selected.gamma_multiply(0.5), + (true, false) => Color32::from_gray(0x70), + (false, true) => t.keyframe_selected, + (false, false) => Color32::from_gray(0xe0), + }; + (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) +} + struct Curve<'a> { layer: &'a Layer, prop: &'a Property, @@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; - if ks && show && (bezier || speed) { + if ks && !k.muted && show && (bezier || speed) { handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); } } @@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl } else { k.value.components().get(d).copied().unwrap_or(0.0) }; - keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles }); } out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); } @@ -221,7 +233,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -251,14 +263,16 @@ struct BarBtn<'a> { fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool { let BarBtn { label, on, id, tip } = btn; let t = app.tokens; - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -270,7 +284,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, b Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() @@ -522,9 +536,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + let (col, edge) = key_colors(&t, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); - pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 5fb4d703c..8e4f3502f 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -104,7 +104,16 @@ fn render_tex( match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); @@ -310,13 +319,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); - // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any - // tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out). + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer and a + // two-finger trackpad scroll pans with any tool; the Hand tool or Space-drag pans and the + // Zoom tool clicks zoom in (Alt: out). let hovered = ui.rect_contains_pointer(area); if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) { - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.is_finite() && scroll.abs() > 0.1 { - zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp()); + let scroll = super::viewer_scroll(&ctx); + if (scroll.zoom - 1.0).abs() > 3e-4 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * scroll.zoom); + } else if scroll.pan != egui::Vec2::ZERO { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + scroll.pan.x, pan.y + scroll.pan.y]; } } // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 14507b868..713dfe6d4 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -24,10 +24,11 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { - project: std::sync::Arc, - history: effectcraft_engine::history::History, +pub(crate) struct Checkpoint { + pub(crate) project: std::sync::Arc, + pub(crate) history: effectcraft_engine::history::History, } impl std::fmt::Debug for Checkpoint { @@ -36,7 +37,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -258,25 +259,32 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - if changed { - // Snapshot only when Cancel has something to restore: opening and cancelling without - // edits must not copy the whole TextDoc. - let edited = active_editor_snapshot(&app.session); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_after_replacement(edited.as_ref()); - app.session.bump(); - } else { - // No edit was committed, so nothing can have become invalid. In particular, do not - // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. - app.session.history = checkpoint.history; - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + if changed { + // Snapshot only when Cancel has something to restore: opening and cancelling without + // edits must not copy the whole TextDoc. + let edited = active_editor_snapshot(&app.session); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); + } else { + // No edit was committed, so nothing can have become invalid. In particular, do not + // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. + app.session.history = checkpoint.history; + } + app.session.history.merge_key = None; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a3c4a85f6..fcb98cce1 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."), Tokens::ui(11.5), t.text_faint, ); @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..83d0d2836 --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,129 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, + /// The project as that command left it, to tell edits made since from the preview's. + applied_at: Option, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => { + app.dialog_state.live.applied = Some((command, params)); + app.dialog_state.live.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + let at = app.dialog_state.live.applied_at.take(); + // Only what the preview did is taken back: an edit made while the dialog was open stays. + preview_merge::rollback(app, cp, at.as_ref()); + } + app.dialog_state.live.applied = None; + app.dialog_state.live.applied_at = None; +} + +/// OK: the preview's result is the edit. The preview is first brought up to `command` with +/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last +/// preview). Returns whether the project then already has the edit (the caller must not run the +/// command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool { + sync(app, command, params); + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 14e774f67..cc08f223d 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -285,8 +285,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: let x = tm.x(comp_t); let col = marker_color(&t, m.label); let h = (y1 - y0).min(10.0); - if m.duration > Tick(0) { - let xe = tm.x(comp_t + m.duration.seconds()); + let end = (m.duration > Tick(0)).then(|| tm.x(comp_t + m.duration.seconds())); + if let Some(xe) = end { let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0)); p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 })); if m.protected { @@ -300,7 +300,20 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: } p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { - p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); + // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field + // colours, so it follows Dark, Darker and Light. + let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); + let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0); + // A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead. + if let Some(xe) = end + && xe > box_x + && xe < box_x + box_w + { + box_x = xe + 3.0; + } + let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0))); + p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); } let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1)); let id = egui::Id::unique(("marker", r.layer, r.index)); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfa9a42e0..2fec0e4c2 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that @@ -42,7 +43,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))) + .on_hover_text(tr(tip)) + .clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); @@ -61,7 +65,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = y + 46.0; // Shortcut. - label(yy, "Shortcut"); + label(yy, tr("Shortcut")); let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -91,14 +95,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = if i < 4 { i / 2 } else { i - 2 }; let cx = x0 + col as f32 * 110.0 - 2.0; let cy = yy + row as f32 * 22.0; - if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + if check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on) { changes.push(json!({"shortcut": current.id(), key: !on})); } } yy += 4.0 * 22.0 + 6.0; // Range (and the pre/post-roll of Play Around Current Time). - label(yy, "Range"); + label(yy, tr("Range")); let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; if let Some(i) = dropdown_row( @@ -116,7 +120,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if o.range == PreviewRange::AroundCurrentTime { for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { let lx = xv + k as f32 * 100.0; - p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + p.text(pos2(lx, yy), Align2::LEFT_CENTER, tr(text), Tokens::ui(11.5), t.text_dim); let (r, nv, _) = widgets::hot_number_at( ui, pos2(lx + 52.0, yy - 9.0), @@ -134,7 +138,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Play From. - label(yy, "Play From"); + label(yy, tr("Play From")); let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -151,7 +155,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame Rate, Skip, Resolution. let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); - label(yy, "Frame Rate"); + label(yy, tr("Frame Rate")); const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); let mut rates = vec![auto.clone()]; @@ -180,7 +184,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); } yy += 24.0; - label(yy, "Resolution"); + label(yy, tr("Resolution")); let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; if let Some(i) = dropdown_row( @@ -209,7 +213,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } yy += 24.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", tr("Full Screen"), o.full_screen) { changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); } yy += 24.0; @@ -217,7 +221,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // What stopping does. p.text(pos2(x0, yy), Align2::LEFT_CENTER, tr_args("On ({}) stop:", &[&(current.label())]), Tokens::ui(11.5), t.text_dim); yy += 20.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", tr("If caching, play cached frames"), o.play_cached_frames) { changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); } yy += 22.0; diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs new file mode 100644 index 000000000..ab41e3167 --- /dev/null +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -0,0 +1,204 @@ +//! Taking a dialog's Preview back without taking other edits with it. +//! +//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the +//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the +//! project changed in the meantime for another reason (a camera analysis finishing, a script, the +//! control channel), swapping the checkpoint in would throw that edit away. This module undoes +//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project +//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch +//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched +//! keeps the other edit. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use effectcraft_engine::commands::text_edit::active_editor_snapshot; +use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project}; + +use crate::EffectcraftApp; +use crate::panels::layer_styles_dialog::{Checkpoint, restore}; + +/// The project right after a preview was applied, and the undo depth then. +#[derive(Clone)] +pub(crate) struct Applied { + project: Arc, + undo_len: usize, +} + +impl std::fmt::Debug for Applied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive() + } +} + +/// Note the project as the preview that has just been applied left it. +pub(crate) fn applied(app: &EffectcraftApp) -> Applied { + Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() } +} + +/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is +/// still what the preview made (or nothing says what that was) the checkpoint is simply restored. +pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) { + let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else { + restore(app, checkpoint); + return; + }; + let merged = merge(&checkpoint.project, &applied.project, &app.session.project); + // One step for what was done meanwhile, from the project before the dialog: Undo takes it back. + let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string()); + let mut history = checkpoint.history; + history.undo.push((label, checkpoint.project)); + history.redo.clear(); + history.merge_key = None; + let edited = active_editor_snapshot(&app.session); + app.session.project = Arc::new(merged); + app.session.history = history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); +} + +/// `now` without the changes between `before` and `after`, keeping any other change. +fn merge(before: &Project, after: &Project, now: &Project) -> Project { + let mut merged = now.clone(); + let ids: BTreeSet = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect(); + for id in ids { + match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) { + Some(it) => { + merged.items.insert(id, it); + } + None => { + merged.items.remove(&id); + } + } + } + merged +} + +/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the +/// preview goes back to `before`, touched by both is `both`. +fn pick(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option) -> Option { + if after == before { + now.cloned() + } else if now == after { + before.cloned() + } else { + both() + } +} + +fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option { + pick(before, after, now, || { + if let (Some(b), Some(a), Some(n)) = (before, after, now) + && let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind) + { + let mut merged = n.clone(); + // Name, folder and the like: only a preview that changed the item itself and nothing else + // is taken back; the comp's own contents are merged below. + merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc))); + return Some(merged); + } + now.cloned() + }) +} + +fn bare(c: &Comp) -> Comp { + let mut b = c.clone(); + b.layers.clear(); + b +} + +fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> { + c.layers.iter().find(|l| l.id == id) +} + +fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { + // The comp's own settings, apart from its layers. + let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now)); + let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned()); + // Layers as they stand now, in their order, then the ones the preview removed put back. + let mut layers: Vec = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect(); + for (at, l) in before.layers.iter().enumerate() { + if layer_of(now, l.id).is_none() + && let Some(back) = merge_layer(l.id) + { + layers.insert(at.min(layers.len()), back); + } + } + merged.layers = layers.into(); + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use crate::panels::live_preview; + use effectcraft_engine::Session; + use serde_json::json; + + fn app_with_light() -> (EffectcraftApp, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + (EffectcraftApp::new(s), light) + } + + fn name_of(app: &EffectcraftApp, layer: u64) -> String { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone() + } + + fn layer_count(app: &EffectcraftApp) -> usize { + app.session.active_comp().unwrap().layers.len() + } + + /// The preview renames the light; a null is added meanwhile (what a finished background job + /// would do). Cancel takes the rename back and keeps the null, with one undo step for it. + #[test] + fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + let rename = |name: &str| json!({"layer": light, "name": name}); + live_preview::sync(&mut app, "layer.rename", rename(&original)); + live_preview::sync(&mut app, "layer.rename", rename("Previewed")); + assert_eq!(name_of(&app, light), "Previewed"); + let layers = layer_count(&app); + app.session.execute("layer.newNull", json!({})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original, "the preview is taken back"); + assert_eq!(layer_count(&app), layers + 1, "the other edit stays"); + assert!(app.session.undo()); + assert_eq!(layer_count(&app), layers, "and has its own undo step"); + assert_eq!(name_of(&app, light), original); + } + + /// With nothing else done meanwhile the checkpoint is restored as before. + #[test] + fn cancelling_an_undisturbed_preview_restores_the_checkpoint() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + let steps = app.session.history.undo.len(); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original); + assert_eq!(app.session.history.undo.len(), steps); + } + + /// An item both the preview and another edit changed keeps the other edit. + #[test] + fn a_layer_both_edits_changed_keeps_the_other_edit() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), "Renamed elsewhere"); + } +} diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1944f333f..cdf57de27 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -812,7 +812,7 @@ fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip))); if resp.clicked() { @@ -1230,12 +1230,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim); proxy_indicator(app, ui, &g, it, &mut actions); - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c35108937..a7563a6ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let project = app.session.project.clone(); let expr = app.session.expr.clone(); let now = app.session.time(); - let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &project, + comp_id: cid, + comp: &comp, + time: now, + expr: expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); @@ -200,6 +209,8 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { #[allow(clippy::too_many_arguments)] fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = t.for_value(pr.has_expression()); let cy = r.center().y; let uid = pr.uid; let lid = layer.id.0; @@ -276,7 +287,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match &value { Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { let (_, deg) = super::fx_widgets::split_angle(*v); - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &vt); app.auto.add(&format!("{base}.value"), dr, &pr.name); app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); if let Some(nv) = nv { @@ -291,7 +302,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid)), *v, &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value"), vr, &pr.name); if let Some(nv) = nv { @@ -323,7 +334,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..321224c64 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -474,7 +494,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -487,7 +507,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } } diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 0d9eefe45..ca3c56ac5 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -69,7 +69,16 @@ pub fn draw_overlay( let ov = match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index affd46317..7a2cecd30 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( out_rect.center(), Align2::CENTER_CENTER, - "JavaScript with the After Effects scripting object model — try app.project.numItems", + crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 242626272..753b2884e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -26,6 +26,8 @@ enum Act { Close(u32), /// A mouse event (`scriptui.mouse` parameters without window and widget). Mouse(u32, u32, Value), + /// A docked panel's new size (`scriptui.resize`). + Resize(u32, [f32; 2]), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -261,20 +263,22 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } } +/// A draw-list colour (`None` is the theme colour). Script colours are display (sRGB) values +/// in 0..1, as in After Effects, not linear light: `[43/255, 43/255, 43/255, 1]` is RGB 43. +fn draw_color(c: &Option<[f32; 4]>, theme: Color32) -> Color32 { + c.map_or(theme, super::gradient_editor::to_color32) +} + /// Paint an onDraw draw list in `rect` (control coordinates → screen). fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) { let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - let col = |c: &Option<[f32; 4]>, theme: Color32| match c { - Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), - None => theme, - }; let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); for op in ops { match op { DrawOp::Fill { color, path } => { // Any path shape (concave, self-intersecting, with holes): non-zero winding // triangles, not egui's convex-only polygon fill. - let c = col(color, t.field_bg); + let c = draw_color(color, t.field_bg); let mut mesh = egui::Mesh::default(); for tri in PathSeg::fill_triangles(path, 48, false) { let base = mesh.vertices.len() as u32; @@ -288,14 +292,14 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: & } } DrawOp::Stroke { color, width, path } => { - let s = Stroke::new(*width as f32, col(color, t.text)); + let s = Stroke::new(*width as f32, draw_color(color, t.text)); for (pts, closed) in PathSeg::polylines(path, 48) { let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); } } DrawOp::Text { text, color, x, y, size, .. } => { - p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), draw_color(color, t.text)); } DrawOp::Image { image, x, y, w, h } => { let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services)); @@ -393,7 +397,11 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & return; } let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let (latest, events, mods, time, held) = ui.input(|i| { + let held = |b: egui::PointerButton, bit: u8| if i.pointer.button_down(b) { bit } else { 0 }; + let held = held(egui::PointerButton::Primary, 1) | held(egui::PointerButton::Secondary, 2) | held(egui::PointerButton::Middle, 4); + (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time, held) + }); let ctx = ui.ctx(); let layer = ui.layer_id(); let target = |p: egui::Pos2| -> Option<&Widget> { @@ -412,7 +420,7 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & w.id, json!({ "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, - "button": button, "detail": detail, + "button": button, "buttons": held, "detail": detail, "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, }), )); @@ -429,10 +437,15 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & } st.hover = now_id; } - if let Some((w, p)) = now - && st.at != Some(p) - { - send(w, "mousemove", p, 0, 0, acts); + // While a button is held, the control it was pressed on keeps getting the moves, inside or + // outside the window, as in After Effects (#2584). + let captured = st.down.and_then(|(c, _)| root.find(c)); + if let Some(p) = latest.filter(|p| st.at != Some(*p)) { + match (captured, now) { + (Some(c), _) => send(c, "mousemove", p, 0, 0, acts), + (None, Some((w, _))) => send(w, "mousemove", p, 0, 0, acts), + (None, None) => {} + } } st.at = latest; // Presses and releases. @@ -444,7 +457,9 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & egui::PointerButton::Secondary => 2, _ => continue, }; - let Some(w) = target(*pos) else { + // A release goes to the control the button was pressed on, wherever the pointer is. + let pressed_on = st.down.filter(|(_, db)| !*pressed && *db == b).and_then(|(c, _)| root.find(c)); + let Some(w) = pressed_on.or_else(|| target(*pos)) else { if !pressed { st.down = None; } @@ -458,7 +473,8 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & send(w, "mousedown", *pos, b, st.clicks, acts); } else { send(w, "mouseup", *pos, b, st.clicks, acts); - if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + let over = target(*pos).is_some_and(|t| t.id == w.id); + if over && st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { // The click with the pointer's details, after mousedown / mouseup, in place of // the plain click the control reported. acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); @@ -486,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})), Act::Mouse(w, id, mut v) => { if let Some(o) = v.as_object_mut() { o.insert("window".into(), json!(w)); @@ -568,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { // A modal script dialog blocks the panels too. let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; + // The script learns its dock's size when it changes (#2585), as After Effects tells a + // docked panel: its window takes the size and onResize runs. + let dock = [rect.width().round(), rect.height().round()]; + let told = egui::Id::unique(("scriptui-dock-size", id)); + if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 { + ui.data_mut(|d| d.insert_temp(told, dock)); + acts.push(Act::Resize(id, dock)); + } let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); @@ -595,3 +620,47 @@ pub fn sync_panels(app: &mut EffectcraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Script colours are sRGB, not linear: `#2B2B2B` drew as RGB 114 when read as linear light. + #[test] + fn draw_colours_are_srgb() { + let v = 43.0 / 255.0; + assert_eq!(draw_color(&Some([v, v, v, 1.0]), Color32::RED), Color32::from_rgb(43, 43, 43)); + let orange = [242.0 / 255.0, 140.0 / 255.0, 40.0 / 255.0, 1.0]; + assert_eq!(draw_color(&Some(orange), Color32::RED), Color32::from_rgb(242, 140, 40)); + assert_eq!(draw_color(&None, Color32::RED), Color32::RED, "no colour is the theme colour"); + } + + /// fillPath, strokePath and drawString all paint the script's colour as sRGB. + #[test] + fn paint_draw_paints_srgb_colours() { + let v = 43.0 / 255.0; + let color = Some([v, v, v, 1.0]); + let path = vec![PathSeg::R { x: 0.0, y: 0.0, w: 10.0, h: 10.0 }]; + let ops = [ + DrawOp::Fill { color, path: path.clone() }, + DrawOp::Stroke { color, width: 1.0, path }, + DrawOp::Text { text: "Hi".into(), color, x: 0.0, y: 0.0, size: 12.0, style: String::new() }, + ]; + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| { + paint_draw(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 50.0)), &ops, &t, &effectcraft_engine::FsServices); + }); + out.textures_delta.clear(); + let want = Color32::from_rgb(43, 43, 43); + let (mut fill, mut stroke, mut text) = (false, false, false); + for s in out.shapes { + match s.shape { + egui::Shape::Mesh(m) => fill |= m.vertices.iter().all(|v| v.color == want), + egui::Shape::Path(p) => stroke |= matches!(p.stroke.color, egui::epaint::ColorMode::Solid(c) if c == want), + egui::Shape::Text(t) => text |= t.fallback_color == want, + _ => {} + } + } + assert!(fill && stroke && text, "fill {fill}, stroke {stroke}, text {text}"); + } +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 692f48236..562a90132 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { - let mut x = v.as_i64().unwrap_or(0); - // A typed number applies once entered: typing 85 into UI Scale - // doesn't resize the interface to 75 % at its first digit. + // A dragged value applies on release, and a typed one once + // entered: UI Scale doesn't resize the interface under the + // pointer mid-drag (#2582) or at a typed number's first digit. + let held = egui::Id::unique(("settings-drag", key)); + let mut x = ui.data(|d| d.get_temp::(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0)); let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(tr(unit)).color(t.text_dim)); } - if r.changed() { + if r.dragged() { + ui.data_mut(|d| d.insert_temp(held, x)); + } else if r.changed() || r.drag_stopped() { + ui.data_mut(|d| d.remove::(held)); acts.push(Act::Set(key.into(), json!(x))); } r @@ -803,9 +808,9 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if m < 1 { "less than a minute ago".to_string() } else if m < 120 { - format!("{m} minutes ago") + tr_args("{} minutes ago", &[&m]) } else { - format!("{} hours ago", m / 60) + tr_args("{} hours ago", &[&(m / 60)]) } }); ui.label(tr_args("Latest auto-save: {}", &[&a])); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 2721c6750..60b908069 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ff8778a15..5a1ac35a0 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Start from a ready-made project. Opening a template makes an untitled copy.", + crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."), Tokens::ui(11.5), t.text_faint, ); // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 0082abf80..7c3222c48 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -3,7 +3,7 @@ //! otherwise they apply to the whole selected text layer. use crate::i18n::tr; -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, LineJoin, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .iter() .filter_map(|id| comp.layer(*id)) .find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?; - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None }) } @@ -182,6 +191,20 @@ fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: } } +/// Line Join of the stroke: Miter / Round / Bevel (grid row 5, under the stroke width). +fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) { + let Grid { x0, y, w, enabled } = g; + let bw = (w - 2.0 * 4.0) / 3.0; + for (i, (key, label, join)) in + [("miter", "Miter", LineJoin::Miter), ("round", "Round", LineJoin::Round), ("bevel", "Bevel", LineJoin::Bevel)].into_iter().enumerate() + { + let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y + 5.0 * 28.0), vec2(bw, 22.0)); + if toggle(app, ui, p, r, &format!("character.lineJoin.{key}"), (label, doc.line_join == join, false)) && enabled { + actions.push(json!({"lineJoin": key})); + } + } +} + /// The Character panel for the text being edited (or the selected text layer): font family and /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning, /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes. @@ -224,7 +247,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { numeric_fields(app, ui, &p, grid, &doc, &mut actions); kerning_field(app, ui, &p, grid, &doc, &mut actions); stroke_order_field(app, ui, grid, &doc, &mut actions); - y += 5.0 * 28.0 + 8.0; + line_join_field(app, ui, &p, grid, &doc, &mut actions); + y += 6.0 * 28.0 + 8.0; // Fill / stroke swatches with their enable boxes. for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); @@ -631,6 +655,17 @@ struct FontSearch { } /// Shared font menu for Character and Properties: search, recent fonts and previews. +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); app.auto.add(auto, rect, "Font family"); @@ -701,6 +736,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app_ref.ui.status = format!("{}…", tr("Rescan Fonts")); + } + app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let toggled = row.clicked() || boxed.clicked(); if toggled { only_variable = !only_variable; @@ -763,10 +807,11 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == selected, &r.display)); let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height())); + let name = drawn_font_name(ui.ctx(), r); ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( label_rect.left_center(), Align2::LEFT_CENTER, - &r.display, + name, Tokens::ui(12.0), t.text, ); @@ -800,7 +845,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: if resp.clicked() { chosen = Some(r.family.clone()); } - resp.on_hover_text(&r.display); + resp.on_hover_text(name); } chosen }; @@ -946,6 +991,13 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token } } +/// The name a font menu row draws: the font's own-language name when the interface fonts have a +/// glyph for each of its characters, else its English family name. A Simplified Chinese name +/// such as 微软雅黑 can have characters that the Han face of an English interface lacks. +fn drawn_font_name<'a>(ctx: &egui::Context, row: &'a effectcraft_engine::FontRow) -> &'a str { + if ctx.fonts_mut(|f| f.has_glyphs(&Tokens::ui(12.0), &row.display)) { &row.display } else { &row.family } +} + /// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, /// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; /// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview @@ -1004,4 +1056,17 @@ mod tests { assert!(fit_preview(&[], room, baseline).is_none()); assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); } + + /// A font menu row draws the font's own-language name when the interface fonts have its + /// glyphs, and the English family name when they do not. + #[test] + fn font_menu_rows_draw_a_name_the_interface_fonts_have() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(Default::default()), "en"); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let row = |display: &str| effectcraft_engine::FontRow { family: "Menu Name Test".into(), display: display.into(), recent: false, variable: false }; + // U+F1234 is in a private use plane that no interface font maps. + assert_eq!(drawn_font_name(&ctx, &row("Menu \u{F1234}")), "Menu Name Test"); + assert_eq!(drawn_font_name(&ctx, &row("Nom du menu")), "Nom du menu"); + } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..9f308e4be 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -158,11 +158,13 @@ fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, } /// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the -/// layer's parent pick whip, an expression's and a property's (Parent & Link column). +/// layer's parent pick whip, an expression's, a property's (Parent & Link column) and the layer's +/// track matte pick whip. type PickWhip = (u8, u64, u64, Pos2); const WHIP_PARENT: u8 = 0; const WHIP_EXPR: u8 = 1; const WHIP_PROP: u8 = 2; +const WHIP_MATTE: u8 = 3; /// The `egui::Id` under which a pick-whip drag in progress ([`PickWhip`]) is kept during the /// drag. @@ -170,6 +172,11 @@ fn pick_whip_id() -> egui::Id { egui::Id::unique("tl-pickwhip") } +/// The layers a layer pick whip (parent, track matte) links, taken as its drag starts. +fn whip_layers_id() -> egui::Id { + egui::Id::unique("tl-pickwhip-layers") +} + /// The automation id, label and hover hint of a pick whip. struct WhipText<'a> { auto: &'a str, @@ -193,7 +200,15 @@ fn pick_whip( app.auto.add(auto, rect, label); if resp.drag_started() { let state: PickWhip = (kind, layer, prop, rect.center()); - ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state)); + let selected = &app.session.state.selected_layers; + let layers = match (kind, app.session.active_comp()) { + (WHIP_MATTE, Some(comp)) => video_targets(comp, LayerId(layer), selected), + _ => edited_layers(LayerId(layer), selected), + }; + ui.ctx().data_mut(|d| { + d.insert_temp(pick_whip_id(), state); + d.insert_temp(whip_layers_id(), layers); + }); } resp } @@ -795,12 +810,11 @@ fn display_name(app: &EffectcraftApp, l: &Layer) -> String { /// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group /// brings its whole contents. fn search_rows(l: &Layer, q: &str) -> Vec { - /// The row for a group in search results: depth, its effect's enabled flag, and the eye - /// switch for layer-style groups (not Blending Options). + /// The row for a group in search results: depth, its effect's enabled flag, and its eye + /// switch ([`eye_of`]). fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row { let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); - let eye = - (parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled); + let eye = eye_of(parent, g); Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } } } /// Every visible child of `g` at `depth`, groups recursed into whole — a matching group @@ -1235,6 +1249,14 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo /// in `open`, text animators list their selectors and properties directly (as in AE), and a /// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items /// list directly under the group. +/// The eye switch of group `g` inside `parent`, as in After Effects: layer styles (not Blending +/// Options) and every item in a shape layer's Contents (#959), which the renderer skips when off. +fn eye_of(parent: &PropGroup, g: &PropGroup) -> Option { + let style = parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING; + let shape_item = parent.match_id == "contents" && g.match_id != "transform"; + (style || shape_item).then_some(g.enabled) +} + fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -1250,9 +1272,7 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); - // Layer style groups have eye switches (not Blending Options). - let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING) - .then_some(sg.enabled); + let eye = eye_of(g, sg); rows.push(Row { layer: l.id, depth, @@ -1293,10 +1313,31 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon } /// Layers being dragged to a new place in the stack (timeline rows). +#[derive(Clone)] +struct LayerDrag { + comp: ItemId, + owner: egui::Id, + layers: Vec, +} + fn layer_drag_id() -> egui::Id { egui::Id::unique("tl-layer-drag") } +/// An effect, mask, shape item or text animator being dragged to a new place among its +/// siblings (#2210). +#[derive(Clone)] +struct GroupDrag { + comp: ItemId, + owner: egui::Id, + layer: LayerId, + uid: u64, +} + +fn group_drag_id() -> egui::Id { + egui::Id::unique("tl-group-drag") +} + /// A keyframe drag in progress: where the pointer started, the grabbed key's comp time /// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset /// (Ctrl/Cmd+Alt), how many layers it staggers. @@ -1388,8 +1429,9 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } -/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): -/// the selection when the row is selected, else that layer alone, as in After Effects. +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode, +/// its parent or track matte, moving its bar): the selection when the row is selected, else that +/// layer alone, as in After Effects (#1260). fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } } @@ -1415,9 +1457,10 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() } -/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) -/// acts on: [`edited_layers`] that have video. -fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { +/// The layers a Modes column edit (blending mode, Preserve Transparency) or a Track Matte edit +/// on `layer`'s row acts on: [`edited_layers`] that have video. Selected nulls, cameras and +/// lights (no Modes or Track Matte on their rows) are left out. +fn video_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() } @@ -1485,15 +1528,15 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele let anchor = ctx.data(|d| d.get_temp::(anchor_id(cid))).or_else(|| selected.first().copied())?; let a = comp.layers.iter().position(|l| l.id == anchor)?; let b = comp.layers.iter().position(|l| l.id == layer)?; - let span = comp.layers.get(a.min(b)..=a.max(b))?; - Some(span.iter().filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) + let span = comp.layers.iter().skip(a.min(b)).take(a.max(b) - a.min(b) + 1); + Some(span.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) } /// Set on the frame the reorder drag is released. /// Where layers dropped at height `py` land among the visible layer rows: above the first row /// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that /// and the y of the line that shows it (`top` without rows). -fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option, f32) { +fn insertion(layer_rows: &[(Rect, T)], py: f32, top: f32) -> (Option, f32) { let at = layer_rows.iter().position(|(r, _)| py < r.center().y); let y = match at { Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y), @@ -1502,10 +1545,18 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option egui::Id { - egui::Id::unique("tl-layer-drop") +/// Resolve a visible insertion boundary through the full outline, including offscreen layers. +fn layer_drop_target(rows: &[Row], visible: &[(Rect, LayerId)], at: Option, moving: &[u64]) -> Option { + let (anchor, after) = match at { + Some(i) => (visible.get(i)?.1, false), + None => (visible.last()?.1, true), + }; + rows.iter() + .filter(|row| matches!(row.kind, RowKind::Layer)) + .skip_while(|row| row.layer != anchor) + .skip(usize::from(after)) + .find(|row| !moving.contains(&row.layer.0)) + .map(|row| row.layer) } /// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` @@ -1520,7 +1571,7 @@ fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { /// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and /// the item wouldn't put the composition inside itself. -fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { +pub(crate) fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { app.session.project.item(item).is_some_and(replaceable_item) && !app.session.project.comp_contains(item, cid) && comp @@ -1721,7 +1772,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -1828,7 +1879,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: g.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; draw_rows(app, ui, g, &ectx); @@ -1839,6 +1899,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { alt_scale_and_graph(app, ui, g, &ectx); graph_empty_area(ui, g); layer_reorder_drag(ui, g); + group_reorder_drag(ui, g); quick_offset_follow(g); key_drag_follow(app, g); key_box_select(app, ui, g); @@ -2152,7 +2213,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T let comp = g.comp.clone(); let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -2903,13 +2964,10 @@ fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let is_sel = rc.is_sel; // Locked layers can't be selected or moved (unlock them with the lock switch). let locked = layer.switches.locked; - if row_resp.drag_started() && !locked { + if row_resp.drag_started_by(egui::PointerButton::Primary) && !locked { // The selected layers move together; an unselected row moves alone. let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving)); - } - if row_resp.drag_stopped() { - ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true)); + ctx.data_mut(|d| d.insert_temp(layer_drag_id(), LayerDrag { comp: cid, owner: row_resp.id, layers: moving })); } app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name); if row_resp.clicked() && !locked { @@ -2996,7 +3054,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let opts: Vec = BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, selected), "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": video_targets(&comp, layer.id, selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); @@ -3006,7 +3064,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: actions.push(( "layer.setSwitch".into(), json!({ - "layers": mode_targets(&comp, layer.id, selected), + "layers": video_targets(&comp, layer.id, selected), "switch": "preserveTransparency", "value": !layer.preserve_transparency }), @@ -3020,10 +3078,25 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let comp = g.tl.comp.clone(); let cw = &g.tl.cw; let actions = &mut g.tl.actions; + let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; - // Track matte: layer picker + kind. - let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); + // Track matte: a pick whip, then the layer picker with the mode. Like the parent, they set + // the matte of every selected layer when the row is selected (#1260). + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.trkmat + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_MATTE, layer.id.0, 0), + WhipText { + auto: &format!("timeline.layer.{}.trackMattePickWhip", layer.id.0), + label: "Track Matte pick whip", + hint: crate::i18n::tr("Track Matte pick whip: drag onto a layer"), + }, + ); + let mr = Rect::from_min_size(pos2(cw.trkmat + 20.0, cy - 9.0), vec2(88.0, 18.0)); let label = match layer.track_matte { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), @@ -3053,7 +3126,7 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { let ids: Vec = candidates.iter().map(|l| l.id).collect(); - if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&video_targets(&comp, layer.id, selected), &ids, i, o)) { actions.push(a); } } @@ -3070,6 +3143,7 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let cw = &g.tl.cw; let actions = &mut g.tl.actions; let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; pick_whip( @@ -3101,8 +3175,10 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc } } if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { - let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; - actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + let par = cands.get(i.wrapping_sub(2)).map(|l| l.id.0); + // (The new parent itself, if selected, stays as it is.) + let layers: Vec = edited_layers(layer.id, selected).into_iter().filter(|l| Some(*l) != par).collect(); + actions.push(("layer.setParent".into(), json!({"layers": layers, "parent": par}))); } } @@ -3270,7 +3346,6 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let selected = &g.selected; let ui_actions = &mut g.ui_actions; let layer = rc.layer; - let is_sel = rc.is_sel; let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if kind == "move" && quick { @@ -3286,7 +3361,7 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let d = frames as f64 * fd; // Moving or trimming a selected layer acts on the whole // selection; each layer trims from its own in/out point. - let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; + let targets = json!(edited_layers(layer.id, selected)); let params = match kind { "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), @@ -3388,11 +3463,16 @@ fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: & app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name); } if let Some(en) = eye { - // Layer style eye switch in the A/V column, like AE. + // A layer style's or shape item's eye switch in the A/V column, like AE. let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0)); lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() { - actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); + let style = layer.props.sub(effectcraft_engine::project::styles::GROUP).is_some_and(|s| s.find_group(*uid).is_some()); + actions.push(if style { + ("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})) + } else { + ("prop.setGroupEnabled".into(), json!({"layer": layer.id.0, "prop": uid, "value": !*en})) + }); } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } @@ -3494,6 +3574,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { /// Group row click and double-click. fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { + let cid = g.tl.cid; let cw = &g.tl.cw; let actions = &mut g.tl.actions; let ui_actions = &mut g.ui_actions; @@ -3502,10 +3583,23 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let indent = rc.indent; let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return }; let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)); - let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click()); + // Effects, masks, shape items and animators drag to a new place among their siblings. + let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance); + let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() }); + if movable && gr.drag_started_by(egui::PointerButton::Primary) { + ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid })); + } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { @@ -3536,7 +3630,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let RowKind::Waveform { item, .. } = &rc.row.kind else { return }; lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); @@ -3546,7 +3640,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { gp.text( pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y), Align2::LEFT_CENTER, - "Building waveform…", + crate::i18n::tr("Building waveform…"), Tokens::ui(11.0), t.text_faint, ); @@ -4025,6 +4119,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + (if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})), ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), @@ -4117,6 +4212,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe None if ks => t.keyframe_selected, None => t.keyframe, }; + // Muted keys stay visible and editable but no longer drive the value. + let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). gp.circle_filled(c, 2.5, kcol); @@ -4238,6 +4335,7 @@ fn pick_whip_drag(g: &mut Rw) { let t = g.tl.t; let graph_x0 = g.tl.graph_x0; let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; let dim_hits = &mut g.dim_hits; @@ -4250,7 +4348,7 @@ fn pick_whip_drag(g: &mut Rw) { let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); // Shown this pass or the last one (Effect Controls may draw after the Timeline). let pass = ctx.cumulative_pass_nr(); - let elsewhere = (target.is_none() && kind != WHIP_PARENT) + let elsewhere = (target.is_none() && !matches!(kind, WHIP_PARENT | WHIP_MATTE)) .then(|| ctx.data(|d| d.get_temp::(whip_targets_id()))) .flatten() .filter(|(p, _)| p.saturating_add(1) >= pass) @@ -4261,10 +4359,13 @@ fn pick_whip_drag(g: &mut Rw) { fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); } if ctx.input(|i| !i.pointer.any_down()) { - ctx.data_mut(|d| { + let whip_layers = ctx.data_mut(|d| { d.remove::(pick_whip_id()); d.remove::(whip_targets_id()); + d.remove_temp::>(whip_layers_id()) }); + // The layers a layer pick whip links, less the one it was dropped on. + let linked = |onto: u64| -> Vec { whip_layers.clone().unwrap_or_else(|| vec![src_layer]).into_iter().filter(|l| *l != onto).collect() }; let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); // The property dropped on, and the value (dimension) if on one of them: `position[0]`. let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr)); @@ -4277,7 +4378,11 @@ fn pick_whip_drag(g: &mut Rw) { }; match (kind, target, prop_target) { (WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => { - actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + actions.push(("layer.setParent".into(), json!({"layers": linked(row.layer.0), "parent": row.layer.0}))); + } + // Each layer keeps its own matte mode. + (WHIP_MATTE, Some((_, row)), _) if row.layer.0 != src_layer && comp.layer(row.layer).is_some_and(|l| l.source.is_av()) => { + actions.push(("layer.setTrackMatte".into(), json!({"layers": linked(row.layer.0), "matte": row.layer.0, "kind": "preserve"}))); } // The property pick whip replaces the expression with the reference (or adds one), // as the expression pick whip does when its expression isn't being edited. @@ -4286,6 +4391,10 @@ fn pick_whip_drag(g: &mut Rw) { if let Some(d) = d { params["target"]["dimension"] = json!(d); } + // Alt held as it drops: an effect's properties by their index (#1072). + if ctx.input(|i| i.modifiers.alt) { + params["byIndex"] = json!(true); + } if let Some((text, [a, b])) = into { // Into the expression being edited, which goes on being edited. params["expression"] = json!(text); @@ -4351,6 +4460,7 @@ fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) { /// Layer reorder drag: a line where the layers land. fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let cid = g.tl.cid; let t = g.tl.t; let rect = g.tl.rect; let graph_x0 = g.tl.graph_x0; @@ -4360,16 +4470,29 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { let p = g.tl.p.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; - if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) { - let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false); + // The origin row can scroll out of view, so finish the gesture here, independently of + // its Response. Disabled UI, Escape or lost ownership cancels; only a primary release drops layers. + if let Some(drag) = ctx.data(|d| d.get_temp::(layer_drag_id())) { + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let valid = ui.is_enabled() + && drag.comp == cid + && drag.layers.iter().all(|id| comp.layer(LayerId(*id)).is_some()) + && !escape + && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(layer_drag_id())); + } + let moving = &drag.layers; let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); if let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) + && valid && !layer_rows.is_empty() { let (at, line_y) = insertion(&layer_rows, py, rows_rect.min.y); - let target = at.and_then(|i| layer_rows[i..].iter().find(|(_, l)| !moving.contains(&l.0)).map(|(_, l)| *l)); + let target = layer_drop_target(&g.tl.rows, &layer_rows, at, moving); if dropped { - ctx.data_mut(|d| d.remove::>(layer_drag_id())); // Only when the order changes (no empty undo step for a drop in place). let mut order: Vec = comp.layers.iter().map(|l| l.id.0).filter(|id| !moving.contains(id)).collect(); let at = target.and_then(|t| order.iter().position(|id| *id == t.0)).unwrap_or(order.len()); @@ -4385,13 +4508,55 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { ctx.set_cursor_icon(egui::CursorIcon::Grabbing); p.with_clip_rect(rows_rect).line_segment([pos2(rect.min.x, line_y), pos2(graph_x0, line_y)], Stroke::new(2.0, t.accent)); } - } else if dropped { - ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer } +/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among +/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels. +fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); + let Some(drag) = ctx.data(|d| d.get_temp::(group_drag_id())) else { return }; + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid)); + let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(group_drag_id())); + } + let Some(parent) = parent.filter(|_| valid) else { return }; + let siblings: Vec = parent.children.iter().map(Node::uid).collect(); + let rows: Vec<(Rect, u64)> = g + .hit_rows + .iter() + .filter_map(|(r, row)| match &row.kind { + RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)), + _ => None, + }) + .collect(); + let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return }; + if rows.is_empty() { + return; + } + let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y); + if !dropped { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent)); + return; + } + // The sibling the drop lands before (or after, below the last visible one). + let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return }; + let from = siblings.iter().position(|u| *u == drag.uid); + let rest: Vec = siblings.iter().copied().filter(|u| *u != drag.uid).collect(); + let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) }; + if let Some(to) = to.filter(|to| Some(*to) != from) { + g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1}))); + } +} + /// Quick Offset of layer bars follows the pointer. fn quick_offset_follow(g: &mut Rw) { let t = g.tl.t; @@ -4974,6 +5139,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"), + ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; @@ -5103,19 +5269,21 @@ fn text_anim_popups( } } -/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track -/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte -/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). -fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { +/// The command for the Track Matte menu item `i` (`choice`) of a row, setting `layers`' mattes, +/// whose menu lists "No Track Matte", a separator, the matte `candidates`, a separator and the +/// modes. A new matte layer keeps each layer's mode (the matte itself, if among them, is left +/// out); a mode without a matte layer takes the layer above, like Layer ▸ Track Matte (#864: it +/// used to do nothing). +fn track_matte_pick(layers: &[u64], candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { if i == 0 { - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + return Some(("layer.setTrackMatte".into(), json!({"layers": layers, "matte": null}))); } if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + let layers: Vec = layers.iter().copied().filter(|l| *l != m.0).collect(); + return (!layers.is_empty()).then(|| ("layer.setTrackMatte".into(), json!({"layers": layers, "matte": m.0, "kind": "preserve"}))); } let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; - Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) + Some(("layer.trackMatte".into(), json!({"layers": layers, "op": kind_name(k)}))) } fn kind_name(k: MatteKind) -> &'static str { @@ -5448,7 +5616,8 @@ fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) { let Value::Scalar(v) = value else { return }; let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid); - let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = app.tokens.for_value(prop.has_expression()); let (x, y) = (at.x, at.y - 9.0); let (min, max, dec, speed) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)), @@ -5456,7 +5625,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> _ => (-1e9, 1e9, 1, 1.0), }; if matches!(prop.ui, ParamUi::Angle) { - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt); app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name); app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -5472,7 +5641,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, 0)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { @@ -5485,6 +5654,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec { let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid); let t = app.tokens; + let vt = t.for_value(prop.has_expression()); let (mut x, y) = (at.x, at.y - 9.0); let is_3d = layer.is_3d(); let mut dims = vec![]; @@ -5495,15 +5665,17 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). - let linkable = feather || (pct && prop.match_id == "scale"); - let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + // Rectangle and Ellipse Path Size have it too, off by default (#2489). + let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse")); + let linkable = feather || size || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !size); if linkable { let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions")); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); - if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { - app.ui.timeline.unlinked.insert(uid); + if resp.clicked() { + app.ui.timeline.toggle_link(uid, !size); } x += 18.0; } @@ -5515,7 +5687,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, d)), c[d], &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); dims.push(r); @@ -5538,7 +5710,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text); x += 6.0; } } @@ -5613,7 +5785,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, ve.set(actions, json!(ni)); } } else { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text); } } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 0355ea5ed..9a5c89f7d 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -79,7 +79,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); let candidates = [top.id, matte.id]; let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { - let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + let (id, params) = track_matte_pick(&[find(s, name).id.0], &candidates, i, choice).expect("a menu item is an action"); s.execute(&id, params) }; pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); @@ -97,7 +97,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); assert!(find(&s, "Chain Bottom").track_matte.is_none()); - assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + assert!(track_matte_pick(&[bottom.id.0], &candidates, 1, "-").is_none(), "a separator does nothing"); } /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. @@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() { " Size", " Position", " Roundness", + " Per-Corner Roundness", + " Top Left Roundness", + " Top Right Roundness", + " Bottom Right Roundness", + " Bottom Left Roundness", " >Fill 1", " Composite", " Fill Rule", @@ -535,3 +540,13 @@ fn disk_bar_range_covers_partial_frames() { let above_limit = TMap { start: 100_001.0, ..tm }; assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); } +#[test] +fn layer_drop_target_skips_moving_layers_beyond_the_viewport() { + let mut rows: Vec = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect(); + rows.insert(4, Row { layer: LayerId(4), depth: 1, kind: RowKind::Prop { uid: 0 } }); + let visible: Vec<_> = (2..=4).map(|id| (Rect::NOTHING, LayerId(id))).collect(); + for at in [Some(2), None] { + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5]), Some(LayerId(6)), "skip moving headers and their properties across the viewport edge"); + assert_eq!(layer_drop_target(&rows, &visible, at, &[1, 4, 5, 6]), None, "the stack ends when no stationary layer follows"); + } +} diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 3edde09df..dac9f7d2b 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -626,7 +626,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = craft_ui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index b5fc582b2..7befef4e2 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -16,7 +16,16 @@ use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; /// Commands that replace or close the open project (Quit is asked when the window closes). -const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"]; +const CLOSING: &[&str] = &[ + "file.newProject", + "file.open", + "file.openRecent", + "file.closeProject", + "file.revert", + "file.openDemoProject", + "templates.create", + "file.runShutdownScripts", +]; /// The command waiting on the prompt, and whether the user already answered it. #[derive(Clone, Debug, Default)] @@ -239,6 +248,23 @@ mod tests { assert!(app.session.is_dirty()); } + #[test] + fn shutdown_scripts_prompt_before_discarding_unsaved_work() { + let (mut app, ctx) = app(); + modified(&mut app); + let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(result, json!({"dialog": "unsavedChanges"})); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + answer_with(&mut app, &ctx, Answer::Cancel); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + answer_with(&mut app, &ctx, Answer::Discard); + assert_eq!(app.session.project.comps().count(), 0); + assert!(!app.session.is_dirty() && app.dialog.is_none()); + } + #[test] fn closing_a_modified_project_asks_and_cancel_keeps_it() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bcae99bc5..d084dd006 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { phase_frame(app, &mut v); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: v.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; phase_guides(app, &ectx, &mut v); phase_layers(app, ui, &ectx, &mut v); phase_scene(app, &ectx, &mut v); @@ -690,16 +699,9 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; - if notices >= 3 { - break; - } + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. + for (notices, (layer, why)) in footage_problems(app, ectx, comp, time).into_iter().enumerate() { let (m, _) = l2c(ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -711,7 +713,6 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } v.vertex_marks = vertex_marks; v.track_hits = track_hits; @@ -719,6 +720,20 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { v.has_3d = has_3d; } +/// Up to three layers active at `time` whose footage, or the proxy in use for footage or a +/// precomp, can't be decoded, each with the reason. +fn footage_problems<'c>(app: &EffectcraftApp, ectx: &EvalCtx, comp: &'c Comp, time: Tick) -> Vec<(&'c Layer, String)> { + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, ectx.project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() +} + fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) { let t = v.t; let cid = v.cid; @@ -935,11 +950,14 @@ fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp } }; ctx.set_cursor_icon(cursor); - // Scroll wheel zooms around the pointer. - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.abs() > 0.1 && area.contains(hp) { - let k = (scroll / 300.0).exp(); - let nz = (zoom * k).clamp(0.01, 32.0); + // The mouse wheel and a trackpad pinch zoom around the pointer; a two-finger trackpad + // scroll pans. + let scroll = super::viewer_scroll(&ctx); + if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + scroll.pan.x, app.ui.viewer.pan[1] + scroll.pan.y]; + } + if (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { + let nz = (zoom * scroll.zoom).clamp(0.01, 32.0); let before = map.to_comp(hp); let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz); let new_center = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0); @@ -1252,6 +1270,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v.puppet_layers = puppet_layers; } +/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view +/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map +/// of the panned view. +fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap { + if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) { + return map; + } + let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1); + if d == egui::Vec2::ZERO { + return map; + } + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + ctx.request_repaint(); + ViewerMap { origin: map.origin + d, ..map } +} + +/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points +/// of an edge of `area`, or past it, toward that edge and faster the further out. +fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 { + const ZONE: f32 = 24.0; + let axis = |p: f32, lo: f32, hi: f32| { + let depth = if p < lo + ZONE { + lo + ZONE - p + } else if p > hi - ZONE { + hi - ZONE - p + } else { + 0.0 + }; + (depth / ZONE).clamp(-3.0, 3.0) * 400.0 + }; + vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y)) +} + fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let t = v.t; let zoom = v.zoom; @@ -1262,6 +1313,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let painter = v.painter.clone(); let gesture: Option = ui.data(|d| d.get_temp(gid)); if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { + let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); match g { @@ -1352,8 +1404,9 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: } } Gesture::Vertices { start, src, applied, inv, layer } => { - // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). - let want = [cpt[0] - start[0], cpt[1] - start[1]]; + // The grabbed vertex snaps (to other layers' vertices and features, guides, grid); + // Shift keeps it to one axis, as a layer move does (#1964). + let want = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift); let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods); let total = [want[0] + c[0], want[1] + c[1]]; let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1])); @@ -1808,7 +1861,7 @@ use geometry::{ pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; +pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..65095b399 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -9,13 +9,13 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); // A Project item dropped on the empty viewer creates the first composition, diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 395219a05..0cd579883 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -4,14 +4,17 @@ use super::{ pos2, }; -/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Visible layers eligible for viewer hit tests, locked or not, in front-to-back order. /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After /// Effects), even when hidden, locked or outside its in-out range. -pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { +fn hit_testable_layers(comp: &Comp, time: Tick) -> impl Iterator { let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); - comp.layers - .iter() - .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) + comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + hit_testable_layers(comp, time).filter(|l| !l.switches.locked) } /// Selectable layers under a comp point, front to back. @@ -24,6 +27,18 @@ pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Laye layers_at(ectx, cpt).next() } +/// Topmost visible layer under a comp point, locked or not. An Alt-drop uses it so a locked layer in +/// front is refused rather than skipped, which would replace the layer behind it. +pub(crate) fn layer_at_including_locked<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + // A layer scaled to nothing has a degenerate quad that `point_in_quad` accepts everywhere. + hit_testable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_area(&q) > 1e-9 && point_in_quad(cpt, &q))) +} + +/// Area of a quad (shoelace formula); zero when its corners coincide or are collinear. +fn quad_area(q: &[[f64; 2]; 4]) -> f64 { + (0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::().abs() / 2.0 +} + /// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in /// After Effects, a selected layer under the point comes before the layers in front of it, so it /// can be dragged where they cover it; otherwise the topmost layer (#230). @@ -202,6 +217,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: time: app.session.time(), expr: None, footage: None, + property_overrides: &[], + media_overrides: &[], }; let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index cb5bffe65..f9d3ef2f4 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } + +/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. +#[test] +fn edge_pan_speed_moves_toward_the_edge() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0)); + assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO); + let left = edge_pan_speed(area, pos2(105.0, 350.0)); + assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}"); + assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge"); + let corner = edge_pan_speed(area, pos2(950.0, 700.0)); + assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}"); +} diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs index 30b3f4d00..59e3e513d 100644 --- a/crates/ui-egui/src/panels/viewer/view.rs +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -44,7 +44,16 @@ pub(super) fn aux_views( // layout shows them), evaluated on a snapshot of the project. let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp, + time: t, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selected = app.session.state.selected_layers.clone(); let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 44364a49d..ac56d9acb 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,7 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped //! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the -//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! pointer, outlined while it is dragged (#88). Alt/Option-dropping a Project item replaces the source of +//! the layer under the pointer instead, as in the Timeline (#2173). Agents do the same with `layer.addItem {position}`, //! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; @@ -10,7 +11,7 @@ use egui::{Rect, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; use super::DragPayload; -use super::viewer::{ViewerMap, layer_at, layer_quad}; +use super::viewer::{ViewerMap, layer_at, layer_at_including_locked, layer_quad}; use crate::EffectcraftApp; /// While something is dragged over the viewer, show where it goes; drop it on release. @@ -23,6 +24,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); + // Alt/Option-drop of a Project item replaces the source of the layer under the pointer (After + // Effects), as in the Timeline; it never adds a layer. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), DragPayload::Item(_)); let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at) .map(|l| { @@ -42,6 +46,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Item(item) if alt_item => alt_replace(app, ectx, painter, map, stroke, at, *item).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -55,7 +60,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Property { .. } => vec![], }; // Layer drops outline the target layer; the others outline the whole viewer. - let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + let layer_drop = alt_item || matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } @@ -80,3 +85,21 @@ fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> { let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 }; (w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32]) } + +/// The `layer.replaceSource` for an Alt/Option-drop of Project item `item` at comp point `at`: the +/// topmost layer there, outlined, if it can take the item as its source (a locked one cannot). +fn alt_replace( + app: &EffectcraftApp, + ectx: &EvalCtx, + painter: &egui::Painter, + map: &ViewerMap, + stroke: Stroke, + at: [f64; 2], + item: u64, +) -> Option<(&'static str, Value)> { + let layer = layer_at_including_locked(ectx, at).filter(|l| super::timeline::replace_source_target(app, ectx.comp_id, ItemId(item), l.id, ectx.comp))?; + if let Some((_, q, _)) = layer_quad(ectx, layer) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + Some(("layer.replaceSource", json!({"layers": [layer.id.0], "item": item, "fixExpressions": false}))) +} diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ef4549db6..ab6d3d561 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -360,31 +395,36 @@ pub(crate) fn motion_path( } let v = k.value.as_vec3(); let s = to_scr(v); - // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's - // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) - }; - for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); - if !has || (tg[0] == 0.0 && tg[1] == 0.0) { - continue; + let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + // A muted position key is off the motion path: no tangents, and a dimmed point. + let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + if !k.muted { + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, kc)); + painter.circle_filled(h, 3.0, kc); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } - let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); - painter.line_segment([s, h], Stroke::new(1.0, col)); - painter.circle_filled(h, 3.0, col); - let side = if out { "out" } else { "in" }; - app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { - painter.rect_filled(r, 0.0, col); + if selected { + painter.rect_filled(r, 0.0, kc); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); @@ -680,6 +720,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3a96d3c50..c1320054a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -307,6 +307,21 @@ impl Default for ViewerState { } impl TimelineState { + /// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size) + /// records it in `linked`. + pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool { + if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) } + } + + /// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]). + pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) { + let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked }; + if !set.remove(&uid) { + set.insert(uid); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -414,6 +429,10 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, + /// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was + /// turned on (#2489). + #[serde(default)] + pub linked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -466,6 +485,7 @@ impl Default for TimelineState { expr_closed: BTreeSet::new(), expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), + linked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, scroll_to: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b1538971f..aa1b30350 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,6 +6,10 @@ use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use egui::FontTweak; +#[cfg(not(target_arch = "wasm32"))] +use egui::emath::GuiRounding as _; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; use serde::{Deserialize, Serialize}; @@ -125,6 +129,16 @@ impl Tokens { pub fn grad_top(&self, c: Color32) -> Color32 { if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } } + + /// The tokens a property's value fields use (#1306): with an expression driving the property + /// (`expression`, [`Property::has_expression`]) its values show in the expression colour, red + /// as in After Effects, hovered and dragged too; otherwise these tokens. For the values only: + /// the name, stopwatch and swatches keep the ordinary tokens. + /// + /// [`Property::has_expression`]: effectcraft_engine::project::Property::has_expression + pub fn for_value(self, expression: bool) -> Tokens { + if expression { Tokens { hot_text: self.expr_text, accent_hover: self.expr_text, ..self } } else { self } + } } impl Tokens { @@ -383,6 +397,47 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ ('ა', "georgian-system"), ]; +/// The size the fallback baseline correction is derived at. The correction is an em fraction, so the +/// value holds at every interface size; any size in the interface's range gives the same answer. +#[cfg(not(target_arch = "wasm32"))] +const BASELINE_METRICS_PX: f32 = 12.0; + +/// A face's ascent and row height at `px`, rounded the way epaint rounds them: ascent, descent and +/// line gap separately to 1/32, then `ascent + descent + line_gap` (epaint's descent is negative, +/// this one is positive). +#[cfg(not(target_arch = "wasm32"))] +fn ascent_and_height(m: &effectcraft_text::fonts::VMetrics) -> (f32, f32) { + let ascent = m.ascent.round_ui(); + let descent = m.descent.round_ui(); + let line_gap = m.line_gap.round_ui(); + (ascent, ascent + descent + line_gap) +} + +/// The `FontTweak` that puts a fallback face's glyphs on the row font's baseline. +/// +/// epaint draws a glyph at `A_face + 0.5·(H_row − H_face)` (`galley_from_rows` in epaint's +/// `text_layout.rs`), where `A` and `H` are the ascent and the row height of the face that draws the +/// glyph and of the row's own font, both from `styled_metrics` in epaint's `font.rs`. Shifting the +/// face by `(A_row − A_face) − 0.5·(H_row − H_face)` cancels the offset; as an em fraction one +/// number serves every size, because `FontTweak` applies `size · y_offset_factor`. +/// +/// The same expression also carries `valign · (max_row_height − line_height)`, which cancels here: +/// every glyph of a row takes the section font's row height, so it is the same term for the Latin +/// and the fallback glyph. The remaining `snap_to_pixel` difference is likewise shared and cancels. +/// (#1309) +/// +/// The UI families do not share a row font — Proportional uses Inter, Monospace JetBrains Mono — +/// and their factors differ by 0.0026 em, which is 0.03 px at 12 pt: inside the 1/32 that is already +/// rounded away above, so one factor per face is enough. The test +/// `fallback_glyphs_sit_on_the_row_baseline` pins the result. +#[cfg(not(target_arch = "wasm32"))] +fn baseline_tweak(row: &effectcraft_text::fonts::Face, face: &effectcraft_text::fonts::Face) -> FontTweak { + let px = BASELINE_METRICS_PX; + let (a_row, h_row) = ascent_and_height(&row.metrics(px)); + let (a_face, h_face) = ascent_and_height(&face.metrics(px)); + FontTweak { y_offset_factor: ((a_row - a_face) - 0.5 * (h_row - h_face)) / px, ..Default::default() } +} + /// Register the system fallbacks for scripts the bundled fonts lack in every font family, and /// return their names in registration order (empty when nothing installed covers a probe, so a /// machine without those fonts simply keeps the bundled ones). A face that covers several probes @@ -415,6 +470,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; + let base_face = fonts::face(base); let han = (han_probe(language), "chinese-system"); let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); @@ -458,6 +514,7 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() }; + data.tweak = baseline_tweak(&base_face, &face); family_fonts.font_data.insert(name.clone(), Arc::new(data)); for family in family_fonts.families.values_mut() { family.push(name.to_string()); @@ -739,6 +796,64 @@ mod script_font_tests { assert_ne!((meem.uv_rect.min, meem.uv_rect.max), (isolated.min, isolated.max), "the meem takes its initial form"); }); } + + /// A fallback face's glyphs sit on the row font's baseline, in every UI family and at every + /// interface size. (#1309) + /// + /// `A` stands in for the row's baseline: epaint exposes none, but a capital without a descender + /// has its ink bottom on the baseline, so its ink bottom *is* the row's baseline. A glyph with a + /// descender (`g`) or a round one with an overshoot (`O`) would quietly stop holding, so keep `A` + /// as the Latin half of this pair. + /// + /// Both glyphs of `Aあ` end on the baseline, so their ink bottoms agree up to the difference + /// between the two outlines themselves - the kana reaches a little below the Latin letter in the + /// Japanese faces. Without the correction the kana sits 3 pt high at 12 pt (measured), so the + /// tolerance below separates "the correction is applied" from "it is missing"; it is not a claim + /// about the outlines. Two pixels per point, because epaint truncates a glyph's box height to + /// whole pixels, which would otherwise put up to a point of noise on the measurement. + #[test] + fn fallback_glyphs_sit_on_the_row_baseline() { + if !installed('\u{3042}') { + eprintln!("no Japanese system font installed; skipping the baseline check"); + return; + } + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + super::install(&ctx, &super::Tokens::for_kind(super::ThemeKind::Dark), "ja"); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + ctx.fonts_mut(|fonts| { + for family in [ + egui::FontFamily::Proportional, + egui::FontFamily::Monospace, + egui::FontFamily::Name("medium".into()), + egui::FontFamily::Name("semibold".into()), + ] { + for size in [12.0_f32, 15.0, 18.0] { + let font = egui::FontId::new(size, family.clone()); + let galley = fonts.layout_no_wrap("A\u{3042}".into(), font, egui::Color32::WHITE); + let row = &galley.rows[0].glyphs; + assert_eq!(row.len(), 2, "A\u{3042} should shape to two glyphs"); + let (latin, kana) = (bottom(&row[0]), bottom(&row[1])); + let drift = kana - latin; + // Without the correction the kana sits 3-5 pt *above* the Latin ink, so the sign is + // what tells the two apart. With it, the kana reaches a little *below* the Latin + // one, by however far the face's kana overshoot past the baseline - measured at + // 1.94 pt for Noto Sans CJK JP against Yu Gothic's 0.4 - plus up to half a point of + // whole-point rounding in `pos.y` for each glyph. + assert!( + drift > -0.5, + "the kana sits {drift:.2} pt above the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is missing or wrong" + ); + assert!( + drift < 2.5, + "the kana sits {drift:.2} pt below the Latin baseline at {size} pt in {family:?} (latin {latin}, kana {kana}); the fallback tweak is too large" + ); + } + } + }); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..ac3368b2e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -602,7 +602,7 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] pub fn layer_picker( ui: &mut Ui, rect: Rect, - layers: &[effectcraft_engine::project::Layer], + layers: &effectcraft_engine::project::Layers, current: Option, t: &Tokens, id: egui::Id, diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 4807e4e40..de2e99ce7 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -197,9 +197,20 @@ impl WorkspaceStore { let path = config.dir().map_or_else(|| FILE.to_string(), |dir| dir.join(FILE).display().to_string()); return Err(format!("{path} couldn't be loaded at startup, so it is left untouched; fix or move it and restart")); } + ensure_positions_ready(ui)?; self.save(Some(config), ui).map(|_| ()) } } +/// Explicit saves must wait until migrated native-window positions refer to their content. +/// Check before capturing a named layout too, so a later background save cannot persist +/// a snapshot made with the old outer-window positions. +pub(crate) fn ensure_positions_ready(ui: &UiState) -> Result<(), String> { + if !ui.native_outer.is_empty() { + return Err("workspace window positions are still being restored; try saving again in a moment".into()); + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs index 93d71fd75..bcb5a9691 100644 --- a/crates/ui-egui/src/workspace_state/legacy.rs +++ b/crates/ui-egui/src/workspace_state/legacy.rs @@ -25,6 +25,9 @@ use super::{Snapshot, VERSION}; use crate::dock::{Layout, PanelKind, Workspace}; use crate::state::UiState; +#[cfg(test)] +mod tests; + #[derive(Deserialize)] enum OldSplitSize { Ratio(f32), @@ -166,7 +169,7 @@ fn node(old: OldNode, depth: usize) -> Result, String> { first: Box::new(node(*a, depth.saturating_add(1))?), second: Box::new(node(*b, depth.saturating_add(1))?), }, - OldNode::Stack { entries } => column(entries)?, + OldNode::Stack { entries } => column(entries, depth)?, }) } @@ -183,7 +186,7 @@ struct Frame { } /// A column of stacked frames as tab groups split top to bottom (see the module docs). -fn column(entries: Vec) -> Result, String> { +fn column(entries: Vec, depth: usize) -> Result, String> { let mut frames: Vec = Vec::new(); // Tabs of closed frames above the first open one. let mut waiting: Vec = Vec::new(); @@ -199,6 +202,9 @@ fn column(entries: Vec) -> Result, String> { } let active = entry.active.min(panels.len().saturating_sub(1)); if entry.open { + if depth.saturating_add(frames.len()) > craft_ui::docking::MAX_DEPTH { + return Err("workspace dock tree exceeds supported limits".into()); + } let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME)); frames.push(Frame { panels, active, height }); } else if let Some(above) = frames.last_mut() { diff --git a/crates/ui-egui/src/workspace_state/legacy/tests.rs b/crates/ui-egui/src/workspace_state/legacy/tests.rs new file mode 100644 index 000000000..a3d182f4f --- /dev/null +++ b/crates/ui-egui/src/workspace_state/legacy/tests.rs @@ -0,0 +1,35 @@ +use super::*; + +fn stack(count: usize) -> OldNode { + OldNode::Stack { + entries: (0..count).map(|_| OldStackEntry { panel: Some(PanelKind::Info), panels: Vec::new(), active: 0, open: true, height: None }).collect(), + } +} + +#[test] +fn stacked_frames_respect_the_remaining_tree_depth() { + let limit = craft_ui::docking::MAX_DEPTH; + assert!(node(stack(limit + 1), 0).is_ok()); + assert!(node(stack(limit + 2), 0).is_err()); + assert!(node(stack(1), limit).is_ok()); + assert!(node(stack(2), limit).is_err()); + assert!(node(stack(limit), 2).is_err()); +} + +#[test] +fn oversized_legacy_stack_load_does_not_abort() { + const CHILD: &str = "EFFECTCRAFT_TEST_LEGACY_STACK_CHILD"; + if std::env::var_os(CHILD).is_some() { + let entry = serde_json::json!({"panel": "Info", "open": true}); + let value = serde_json::json!({"workspaces": {"Oversized": {"Stack": {"entries": vec![entry; 20_000]}}}}); + assert!(serde_json::to_vec(&value).unwrap().len() < super::super::MAX_BYTES); + assert!(migrate(value, &UiState::default()).is_err()); + return; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "workspace_state::legacy::tests::oversized_legacy_stack_load_does_not_abort", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} diff --git a/crates/ui-egui/src/workspace_state/tests.rs b/crates/ui-egui/src/workspace_state/tests.rs index e7b6d9aae..aab2814ba 100644 --- a/crates/ui-egui/src/workspace_state/tests.rs +++ b/crates/ui-egui/src/workspace_state/tests.rs @@ -65,6 +65,29 @@ fn corrupt_future_or_unknown_workspace_keeps_defaults_and_original_file() { /// saved as "My Layout"; then Layer closed. const V1: &str = include_str!("../../tests/fixtures/workspaces-v1.json"); +#[test] +fn explicit_save_reports_pending_migration_and_can_be_retried() { + let config = MemoryConfig::default(); + config.write(FILE, V1).unwrap(); + let mut ui = UiState::default(); + let mut store = WorkspaceStore::load(Some(&config), &mut ui); + assert!(!ui.native_outer.is_empty()); + assert!(!store.save(Some(&config), &ui).unwrap(), "background saves defer quietly"); + let error = store.save_now(Some(&config), &ui).unwrap_err(); + assert!(error.contains("positions") && error.contains("try saving again"), "{error}"); + assert_eq!(config.read(FILE).as_deref(), Some(V1)); + assert_eq!(config.read(BACKUP), None); + assert!(store.has_changes(&ui)); + + crate::dock_ui::native_outer_to_content(&mut ui, None, None, 3.0); + store.save_now(Some(&config), &ui).unwrap(); + assert_eq!(config.read(BACKUP).as_deref(), Some(V1)); + let mut restored = UiState::default(); + let loaded = WorkspaceStore::load(Some(&config), &mut restored); + assert_eq!(loaded.error, None); + assert_eq!(Snapshot::capture(&restored), Snapshot::capture(&ui)); +} + #[test] fn version_one_file_loads_with_every_panel_in_place() { let config = MemoryConfig::default(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..7d38e3089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -8,6 +8,7 @@ mod ui_agent_audit; mod ui_angles; mod ui_audio_scrub; mod ui_azerty_shortcuts; +mod ui_batch_links; mod ui_batch_switches; mod ui_blank_context_menus; mod ui_camera_tracker; @@ -15,11 +16,14 @@ mod ui_character_eyedropper; mod ui_color_hex; mod ui_comp_presets; mod ui_control_modifiers; +mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_env_light_gpu; +mod ui_expression_values; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; @@ -39,6 +43,7 @@ mod ui_layer_source_picker; mod ui_light_settings; mod ui_live_preview; mod ui_locked_color; +mod ui_marker_names; mod ui_media_browser_cache; mod ui_media_browser_copy_path; mod ui_menus; @@ -79,6 +84,7 @@ mod ui_tracker; mod ui_unreadable_footage; mod ui_video_preview_off; mod ui_viewer; +mod ui_viewer_trackpad; mod ui_viewers; mod ui_visual_review; mod ui_web_gpu_storage; diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index c71fadf3c..48e8b8283 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -278,6 +278,31 @@ fn ui_dock_and_window_panel_commands_parse_craft_ui_commands() { assert_eq!(app.ui.dock, Workspace::new(dock::workspace("Default"))); } +#[test] +fn raw_open_actions_are_rejected_through_every_docking_route() { + let mut app = EffectcraftApp::new(Session::default()); + let before = app.ui.dock.clone(); + for panel in [PanelKind::ScriptPanel(77), PanelKind::Viewer(42)] { + for action in [ + Action::Open { panel, anchor: Some(PanelKind::Project) }, + Action::OpenAt { panel, anchor: PanelKind::Project, placement: Placement::Tab { before: None } }, + ] { + for id in ["ui.dock", "window.panel.layout", "window.dockAction"] { + let error = invoke(&mut app, id, json!({"action": action})).unwrap_err(); + assert!(error.contains("panel open commands"), "{id}: {error}"); + assert_eq!(app.ui.dock, before, "{id} must leave the layout unchanged"); + assert!(app.session.script_ui.window(77).is_none()); + assert!(!app.ui.viewers.contains_key(&42)); + } + } + } + // The normal operation still opens a panel and the raw form still activates one. + invoke(&mut app, "ui.dock", json!({"operation": "open", "panel": "Align"})).unwrap(); + assert!(app.ui.dock.is_open(PanelKind::Align)); + invoke(&mut app, "window.panel.layout", json!({"action": {"Activate": {"panel": "Project"}}})).unwrap(); + assert!(app.ui.dock.is_visible(PanelKind::Project)); +} + #[test] fn catalog_close_keeps_dynamic_panel_identity_and_releases_viewers() { let mut app = EffectcraftApp::new(Session::default()); diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index c748afe8e..a1f0a887b 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -166,96 +166,90 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() { assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped"); } -/// A preview with audio waits for frames like a silent one (#103): while the frames ahead aren't -/// cached, every frame shows as it renders, silently; the sound starts once the rest of the -/// preview is cached, and a frame that isn't cached stops it instead of being skipped. +/// The comp's frame at the current time. +fn frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> i64 { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) +} + +/// The frame under the preview audio's first sample (its clock before the device pulls any). +fn audio_frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> Option { + let app = h.state(); + let a = app.audio.as_ref()?; + Some(effectcraft_ui_egui::audio::frame_of_sample(a.clock(), a.rate, app.session.project.comp(cid).unwrap().frame_rate)) +} + +/// Step until the preview's sound starts (or 60 s pass, for a loaded machine), checking the +/// playhead holds `first` meanwhile. Returns whether the sound started. +fn until_sound(h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, first: i64) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + step(h); + if h.state().audio.is_some() { + return true; + } + assert_eq!(frame(h, cid), first, "the playhead holds the first frame until the sound can start"); + assert!(h.state().playback.starting && h.state().playback.playing); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + false +} + +/// One step with the app clock, which playback runs on, a step (1/30 s) further on: these tests +/// count time in steps, however long a step takes on a loaded machine. +fn step(h: &mut Harness<'_, EffectcraftApp>) { + h.state_mut().clock_mut().advance(1.0 / 30.0); + h.step(); +} + +/// Starting a preview with sound before its frames are cached (#947): the playhead holds the +/// first frame while the frames ahead render, then sound and picture start together from it (the +/// sound used to start at a later frame, losing the opening). A frame that isn't cached later on +/// still stops the sound rather than being skipped (#103). #[test] -fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { +fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { let fake = Fake::default(); let (mut app, cid) = setup(true, &fake); - // Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the - // silently shown frames and the moment the sound starts do not depend on the host's threads. + // Release only the frames this test chooses, and drive the app clock: rendering cannot race + // the clock on a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); - let ahead = (app.frames.parallelism() * 2).max(4); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); - // Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock - // explicitly at one frame per step. h.state_mut().use_manual_clock(); h.run_steps(2); gate.discard(&mut h); - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) - }; let now = h.state().now(); h.state_mut().play(now); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Show frames while only those frames are cached: they play silently first. - for _ in 0..4 { - let target = last + 1; - gate.render(&mut h, cid, &[target]); - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - // Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never - // jump over a frame. - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; - } - // A frame that has not rendered holds the clock at the frame on screen; once it renders it is - // shown, never jumped over. This exercises the production hold path, not a test-only shortcut. - let waiting = last; - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped"); - gate.render(&mut h, cid, &[waiting + 1]); - // f64 clock accumulation need not put the next step exactly on the next frame boundary; - // keep stepping until the held frame appears, and never allow it to jump past that frame. - let mut showed_waiting = false; - for _ in 0..4 { - h.state_mut().clock_mut().advance(1.0 / 30.0); - h.step(); - let f = frame(&h); - assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped"); - if f == waiting + 1 { - showed_waiting = true; - break; - } - } - assert!(showed_waiting, "the waiting frame is shown once it renders"); - shown += 1; - last = waiting + 1; - assert!(shown > 0, "frames played silently first"); - assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits"); - // Cache everything ahead; only then may the sound start. - let end = h.state().playback.plan.expect("a preview plan").end; - let rest: Vec = ((last + 1)..=end).collect(); - gate.render(&mut h, cid, &rest); - h.step(); + assert!(h.state().playback.starting && h.state().audio.is_none(), "nothing is cached: no sound yet"); + // Half the frames ahead cached: the playhead holds the first frame, silently. + let half: Vec = (0..ahead / 2).collect(); + gate.render(&mut h, cid, &half); + step(&mut h); + assert!(h.state().playback.starting && h.state().audio.is_none(), "{} of {ahead} frames ahead cached: no sound yet", half.len()); + assert_eq!(frame(&h, cid), 0, "the playhead holds the first frame"); + // All of them: the sound starts, from that frame. + let all: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &all); + step(&mut h); assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame, not a later one"); + assert!(!h.state().playback.starting); // A frame that isn't cached stops the sound rather than being skipped. gate.discard(&mut h); - h.state().frames.set_budget(0); h.state().frames.clear(); h.step(); assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } -/// A preview whose frames render in real time sounds without waiting for the whole work area to -/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead -/// cached, the sound starts, still without skipping a frame before it. +/// A long (30 s) preview sounds once the frames ahead of its first frame are cached, not the +/// whole work area (#208), and from that first frame (#947). #[test] -fn preview_with_audio_sounds_once_rendering_keeps_up() { +fn preview_with_audio_sounds_without_caching_the_work_area() { let fake = Fake::default(); let (mut app, cid) = setup_long(true, &fake, 30.0); - // Release only the frames this test chooses, and drive the app clock: rendering cannot race - // the clock, so a frame is never skipped by a slow or loaded test host. let gate = GatedFrames::default(); let renderer: Arc = Arc::new(gate.clone()); app.frames.set_remote(Some(renderer)); @@ -264,36 +258,424 @@ fn preview_with_audio_sounds_once_rendering_keeps_up() { h.run_steps(2); gate.discard(&mut h); let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + let now = h.state().now(); + h.state_mut().play(now); + let frames: Vec = (0..ahead).collect(); + gate.render(&mut h, cid, &frames); + step(&mut h); + assert!(h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let cached = h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); + assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area"); +} + +/// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. +fn pull(feed: &AudioFeed, frames: usize) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while feed.queued_frames() < frames && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let mut out = vec![0.0f32; frames * 2]; + feed.pull(&mut out); + out +} + +/// The opening of the preview is heard (#947): a sound that lasts only the first 100 ms after the +/// start, from the beginning of the comp or from the middle, reaches the output device. +#[test] +fn a_sound_in_the_first_100_ms_is_heard() { + for start in [0.0, 1.0] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(false, &fake, 4.0); + // A tone only from `start` to `start` + 0.1 s. + let l = app.session.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + app.session.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + app.session.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + app.session.execute("layer.timing", json!({"layers": [l], "in": start, "out": start + 0.1})).unwrap(); + app.session.execute("time.set", json!({"time": start})).unwrap(); + let first = (start * 30.0) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(until_sound(&mut h, cid, first), "{start}: the sound started"); + assert_eq!(audio_frame(&h, cid), Some(first), "{start}: from the first frame"); + let feed = fake.0.lock().unwrap().0.clone().expect("the output opened"); + // The first 100 ms at 48 kHz. + let out = pull(&feed, 4800); + assert!(out.iter().any(|v| v.abs() > 0.01), "{start}: the opening tone reached the output"); + } +} + +/// A preview without video starts its sound at once, from the first frame (unchanged), and so +/// does one whose frames are already cached. +#[test] +fn audio_only_previews_start_at_once() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play_with(now, effectcraft_engine::preview::PreviewShortcut::AltNumpad0); + assert!(h.state().audio.is_some() && !h.state().playback.starting, "Video off: the sound starts at once"); + assert_eq!(audio_frame(&h, cid), Some(0)); +} + +/// Without an output device the preview still plays, from the first frame, once the frames ahead +/// are cached; Stop while it prepares leaves nothing playing. +#[test] +fn no_device_plays_the_picture_and_stop_ends_the_wait() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 10.0); + app.hooks.audio_device = Some(Box::new(|_| None)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let now = h.state().now(); + h.state_mut().play(now); + assert!(h.state().playback.starting); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting && h.state().audio.is_none() && h.state().playback.playing, "no device: the picture plays alone"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "from the first frame on"); + // Stop while preparing. + h.state_mut().stop(); + let now = h.state().now(); + let device = fake.clone(); + h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(device.clone()) as Box))); + h.state().frames.clear(); + h.state_mut().play(now); + // One step into preparing: the frames ahead are requested and it waits for them. + step(&mut h); + assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing"); + h.state_mut().stop(); + // The renders it queued finish after Stop: they must not start the sound. + for _ in 0..30 { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays"); + assert!(fake.0.lock().unwrap().0.is_none(), "the output never opened"); +} + +/// Start a preview and step until it leaves its startup (or 60 s pass). Returns whether it did. +fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { + let now = h.state().now(); + h.state_mut().play(now); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + !h.state().playback.starting +} + +/// RAM preview budgets just below, at and above the prefetch's frames ahead, and small ones, from +/// a cold cache, with and without an output device: the start never waits for a frame the +/// prefetch doesn't request (with a budget of 4 frames it asks for 3 ahead of the playhead). +#[test] +fn startup_finishes_at_any_ram_preview_budget() { + let probe = Fake::default(); + let (app, _) = setup(true, &probe); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. + let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; + fits.sort(); + fits.dedup(); + for fit in fits { + for device in [true, false] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + if !device { + app.hooks.audio_device = Some(Box::new(|_| None)); + } + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + let budget = ((fit as f64 + 0.5) * 9216.0 / 0.9) as usize; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + assert!(until_started(&mut h), "fit {fit} (ahead {ahead}), device {device}: the preview left its startup"); + assert_eq!(h.state().audio.is_some(), device, "fit {fit}, device {device}"); + if device { + assert_eq!(audio_frame(&h, cid), Some(0), "fit {fit}: from the first frame"); + } + } + } +} + +/// An audible 160×120 comp with a 3D layer and Draft 3D, Extended Viewer on: its viewer frames +/// render the pasteboard too, larger than the comp. Returns the harness once the first frame has +/// rendered, the comp, and that frame's size. +fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [usize; 2]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#4060a0"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap(); + s.prefs.three_d.extended_viewer = true; + let mut app = EffectcraftApp::new(s); + let f = fake.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + // The viewer shows the pasteboard: frames render the extended region. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let size = loop { + step(&mut h); + let series = h.state().shown_series(cid); + if let Some(img) = h.state().frames.get(&series) { + break img.size(); + } + assert!(std::time::Instant::now() < deadline, "the first frame rendered"); + std::thread::sleep(std::time::Duration::from_millis(5)); }; + assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on"); + (h, cid, size) +} + +/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized +/// frames holds fewer of them, and the start waits only for as many as really fit (#947). +#[test] +fn startup_finishes_when_extended_viewer_frames_are_larger() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let ahead = (h.state().frames.parallelism() * 2).max(4); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. + let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; + assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + // The first frame renders before the harness clock moves on: each step is 1/30 s however long + // it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that + // clock, and this test is about the wait for the frames ahead. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert!(h.state().audio.is_some(), "with its sound"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; +/// here it can't be cached until the RAM preview grows): the start keeps holding it, then the +/// sound starts from it and keeps playing. It used to give up two seconds after Play, start the +/// sound on the uncached frame and stop it at once, so the opening played silently. +#[test] +fn a_slow_first_frame_keeps_its_opening_sound() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + h.state().frames.set_budget((comp_bytes + real_bytes) / 2); + h.state().frames.clear(); let start = h.state().now(); h.state_mut().play(start); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Two seconds of playback at one frame per clock step: keep the prefetch window cached, but - // not the rest of the 30 s comp, so the sound has to start by the real-time rule. - for step in 1..=60 { - let target = last + 1; - let window: Vec = (target..target + ahead).collect(); - gate.render(&mut h, cid, &window); - // Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated - // f64 error can put one step just below a frame boundary and the next just above it, so - // `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step. - let now = h.state().now(); - let tick_time = start + step as f64 / 30.0; - h.state_mut().clock_mut().advance(tick_time - now); - h.step(); - if h.state().audio.is_some() { - break; - } - let f = frame(&h); - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; + while h.state().now() - start < 3.0 { + step(&mut h); + assert!(h.state().playback.starting, "it still holds the first frame"); + assert_eq!(frame(&h, cid), 0); } - assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); - assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound yet"); + h.state().frames.set_budget(1 << 30); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert_eq!(audio_frame(&h, cid), Some(0), "with its sound from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + +/// An output device whose start fails. +#[derive(Clone, Default)] +struct Broken; + +impl AudioDevice for Broken { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _feed: Arc) -> Result<(), String> { + Err("device unavailable".into()) + } + fn stop(&mut self) {} +} + +/// A device that fails to start: the picture plays on its own from the first frame. +#[test] +fn a_device_that_fails_to_start_plays_the_picture() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + app.hooks.audio_device = Some(Box::new(|_| Some(Box::new(Broken) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h), "it left its startup"); + assert!(h.state().audio.is_none() && h.state().playback.playing); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "the picture plays"); +} + +/// An output that plays: a thread takes the feed's samples in real time, as a sound card would. +#[derive(Clone, Default)] +struct Playing(Arc>); + +impl AudioDevice for Playing { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, feed: Arc) -> Result<(), String> { + let stop = self.0.clone(); + std::thread::spawn(move || { + let mut buf = vec![0.0f32; 480 * 2]; + while !*stop.lock().unwrap() { + feed.pull(&mut buf); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + Ok(()) + } + fn stop(&mut self) { + *self.0.lock().unwrap() = true; + } +} + +/// Once started, the sound's clock moves the picture on from the first frame, and a frame that +/// isn't cached still stops the sound rather than being skipped. +#[test] +fn a_started_preview_follows_its_sound() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + let device = Playing::default(); + let d = device.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(d.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + assert!(until_started(&mut h) && h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) < 5 && std::time::Instant::now() < deadline && h.state().audio.is_some() { + step(&mut h); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(frame(&h, cid) >= 5, "the picture follows the sound's clock (at frame {})", frame(&h, cid)); + h.state().frames.set_budget(0); + h.state().frames.clear(); + step(&mut h); + assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound"); + *device.0.lock().unwrap() = true; +} + +/// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait +/// for frames that can never be cached. The sound isn't started on a frame that can't be shown +/// (a stall would stop it at once): the preview goes on as a stalled one does. +#[test] +fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { + for budget in [0, 9216 / 2] { + let fake = Fake::default(); + let (app, _) = setup_long(true, &fake, 8.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + step(&mut h); + assert!(!h.state().playback.starting, "budget {budget}: no wait"); + assert!(fake.0.lock().unwrap().0.is_none(), "budget {budget}: no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "budget {budget}: it goes on as a stalled preview"); + } +} + +/// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame +/// larger than the whole budget): the start gives up after STARTUP_LIMIT instead of holding the +/// first frame for ever, without starting the sound on a frame it can't show. +#[test] +fn startup_gives_up_on_a_first_frame_that_never_caches() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + // Room for a comp-sized frame but not a real one: the current frame can never be cached. + let budget = (comp_bytes + real_bytes) / 2; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let start = h.state().now(); + h.state_mut().play(start); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + assert_eq!(frame(&h, cid), 0, "it holds the first frame while it waits"); + step(&mut h); + } + let waited = h.state().now() - start; + assert!(!h.state().playback.starting, "it gave up waiting"); + assert!((9.9..12.0).contains(&waited), "after about ten seconds ({waited:.2} s)"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "it goes on as a stalled preview"); +} + +/// Adding markers while a preview sounds (Num*, one per beat) keeps the sound going (#1083): a +/// marker changes no pixels, so no cached frame is dropped and no frame has to be rendered again +/// (which would stop the sound until the frames ahead are cached). +#[test] +fn adding_markers_does_not_stop_the_sound() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + // Everything is cached before the preview starts, so the sound starts at once. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app); + h.run_steps(2); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + while h.state().audio.is_none() && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(h.state().audio.is_some(), "the sound started"); + for beat in 0..6 { + let t = 0.1 + 0.05 * beat as f64; + h.state_mut().session.execute("comp.addMarker", json!({"comp": cid.0, "time": t, "comment": "beat"})).unwrap(); + h.step(); + let app = h.state(); + assert!(app.playback.playing && app.audio.is_some() && !app.playback.audio_held, "marker {beat}: still playing with sound"); + } + assert_eq!(h.state().session.project.comp(cid).unwrap().markers.len(), 6); } diff --git a/crates/ui-egui/tests/it/ui_batch_links.rs b/crates/ui-egui/tests/it/ui_batch_links.rs new file mode 100644 index 000000000..5b2cea090 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_batch_links.rs @@ -0,0 +1,209 @@ +//! Parent and Track Matte for several layers at once (#1260): on a selected layer's row, the +//! Timeline's Parent and Track Matte menus and pick whips set every selected layer; on an +//! unselected row, that layer alone. One undo step each; each layer keeps its own matte mode. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, MatteKind}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use serde_json::json; + +/// Solids A, B, C, P (moved and scaled, a parent) and the mattes X and Y; stacked Y, X, P, C, B, A +/// from the top. The Track Matte column is shown. +fn harness() -> (Harness<'static, EffectcraftApp>, [u64; 6]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Links", "width": 320, "height": 180, "duration": 4})).unwrap(); + let ids = ["A", "B", "C", "P", "X", "Y"] + .map(|n| s.execute("layer.newSolid", json!({"name": n, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap()); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/position", "value": [60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/scale", "value": [50, 50]})).unwrap(); + s.history.undo.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.show_modes = true; + h.state_mut().ui.timeline.columns_w = 900.0; + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.step(); + press(h, at, true); + h.step(); + press(h, at, false); + h.run_steps(3); +} + +/// Open the menu at automation id `dropdown` and choose `item` in it. +fn choose(h: &mut Harness<'_, EffectcraftApp>, dropdown: &str, item: &str) { + let menu = rect(h, dropdown); + // Other rows' menus show their choice as well ("No Track Matte"): the entry is a node the + // opened menu added. + let shown: Vec = h.query_all_by_label(item).map(|n| n.rect()).collect(); + click(h, menu.center()); + // (The menu may open above it to fit the window: the entry nearest to it.) + let off = |r: &Rect| (r.center().y - menu.center().y).abs(); + let at = h.query_all_by_label(item).map(|n| n.rect()).filter(|r| !shown.contains(r)).min_by(|a, b| off(a).total_cmp(&off(b))); + let at = at.unwrap_or_else(|| panic!("no {item} in the {dropdown} menu")).center(); + click(h, at); +} + +/// Drag the pick whip `whip` onto layer `onto`'s row. +fn whip(h: &mut Harness<'_, EffectcraftApp>, whip: &str, onto: u64) { + let (from, to) = (rect(h, whip).center(), rect(h, &format!("timeline.layer.{onto}.row")).center()); + h.event(Event::PointerMoved(from)); + h.step(); + press(h, from, true); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + press(h, to, false); + h.run_steps(3); +} + +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +fn parent(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().parent.map(|p| p.0) +} + +fn position(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + +fn matte(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option<(u64, MatteKind)> { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) +} + +/// The project and its undo steps as they are now, to compare with after an undo or a refusal. +fn mark(h: &Harness<'_, EffectcraftApp>) -> (std::sync::Arc, usize) { + (h.state().session.project.clone(), h.state().session.history.undo.len()) +} + +fn unchanged(h: &Harness<'_, EffectcraftApp>, m: &(std::sync::Arc, usize)) -> bool { + h.state().session.project.same_content(&m.0) && h.state().session.history.undo.len() == m.1 +} + +#[test] +fn parent_menu_and_pick_whip_parent_the_selected_layers() { + let (mut h, [a, b, c, p, ..]) = harness(); + let start = position(&h, a); + select(&mut h, &[a, b]); + let before = mark(&h); + // The Parent menu on A's (selected) row parents A and B, kept where they are. + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [Some(p), Some(p), None]); + assert!(position(&h, a) != start && position(&h, a) == position(&h, b), "both re-expressed in P's space"); + assert_eq!(h.state().session.history.undo.len(), before.1 + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both back"); + // The pick whip of an unselected row parents that layer alone. + whip(&mut h, &format!("timeline.layer.{c}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [None, None, Some(p)]); + // A selected row's whip parents the selection, less the parent itself if selected. + select(&mut h, &[a, b, p]); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, p)], [Some(p), Some(p), None]); + // A cycle (P under A, while A is under P) is refused as a whole. + select(&mut h, &[p, c]); + let before = mark(&h); + choose(&mut h, &format!("timeline.layer.{p}.parent"), "6. A"); + assert!(unchanged(&h, &before), "the refused parenting changed nothing"); + assert_eq!([parent(&h, p), parent(&h, c)], [None, Some(p)]); +} + +#[test] +fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() { + let (mut h, [a, b, c, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": b, "matte": x, "kind": "alpha"})).unwrap(); + select(&mut h, &[a, b]); + let before = mark(&h); + // A new matte layer for both; each keeps its mode; C (not selected) keeps none. + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), None]); + assert!(!h.state().session.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "Y is hidden as a matte"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both mattes and Y's visibility back"); + // A mode for both, then No Track Matte for both. + choose(&mut h, &format!("timeline.layer.{b}.trackMatte"), "Luma Inverted Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [Some((x, MatteKind::LumaInverted)), Some((x, MatteKind::LumaInverted))]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "No Track Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [None, None]); + // The track matte pick whip: a selected row's sets the selection's, an unselected row's + // its own. + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha)), None]); + whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x); + assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]); +} + +fn video(h: &Harness<'_, EffectcraftApp>, id: u64) -> bool { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.video +} + +/// A locked layer in the selection stays selected, and keeps its parent, its transform and its +/// matte while another selected row's menus and pick whips change the rest. +#[test] +fn locked_selected_layers_keep_their_parent_and_matte() { + let (mut h, [a, b, c, _, x, _]) = harness(); + select(&mut h, &[a, b]); + // B alone (a click on its Lock switch locks the whole selection). + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + h.run_steps(2); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(b)).unwrap().switches.locked && selected == vec![a, b], "{selected:?}"); + let b_position = position(&h, b); + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), c); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), x); + assert_eq!((parent(&h, a), matte(&h, a)), (Some(c), Some((x, MatteKind::Alpha))), "A changed each time"); + assert_eq!((parent(&h, b), matte(&h, b), position(&h, b)), (None, None, b_position), "locked B didn't"); +} + +/// Selected nulls (no Track Matte on their rows) get no matte from another row's menu or pick +/// whip: at the top of the comp one used to refuse the whole mode change (nothing above it to +/// take as a matte), at the bottom it took the layer above as its matte and hid it. +#[test] +fn selected_nulls_get_no_matte() { + let (mut h, [a, b, _, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": x, "kind": "alpha"})).unwrap(); + let n = h.state_mut().session.execute("layer.newNull", json!({"name": "N"})).unwrap()["layer"].as_u64().unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Luma Matte"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, n)], [Some((x, MatteKind::Luma)), Some((x, MatteKind::Luma)), None], "a null on top"); + h.state_mut().session.execute("layer.arrange", json!({"layers": [n], "to": "back"})).unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Alpha Inverted Matte"); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((x, MatteKind::AlphaInverted)), Some((x, MatteKind::AlphaInverted)), None], + "a null at the bottom" + ); + assert!(video(&h, a), "A wasn't hidden as the null's matte"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((y, MatteKind::AlphaInverted)), Some((y, MatteKind::AlphaInverted)), None], + "the pick whip too" + ); +} diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index 2ab27fd6e..8c4be61a7 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -16,8 +16,9 @@ const H: f64 = 360.0; fn app() -> (EffectcraftApp, u64, u64) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "Shot", "width": W, "height": H, "duration": 2})).unwrap(); - let plate = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Shot", "width": W as u32, "height": H as u32, "duration": 2, "frameRate": 30})).unwrap(); + let plate = + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": W as u32, "height": H as u32})).unwrap()["layer"].as_u64().unwrap(); let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap(); let uid = r["effects"][0].as_u64().unwrap(); // A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1. @@ -135,7 +136,11 @@ fn viewer_points_selection_and_effect_controls() { h.state_mut().show_panel(PanelKind::EffectControls); h.run_steps(4); let all = ids(&h); - for want in [format!("effectControls.cameraTracker.{uid}.analyze"), format!("effectControls.cameraTracker.{uid}.createCamera")] { + for want in [ + format!("effectControls.cameraTracker.{uid}.analyze"), + format!("effectControls.cameraTracker.{uid}.createCamera"), + format!("effectControls.cameraTracker.{uid}.exportBlender"), + ] { assert!(all.contains(&want), "missing {want}"); } // Tracker panel: Track Camera is available for the selected layer. @@ -145,6 +150,56 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +#[test] +fn blender_export_button_opens_python_dialog_and_writes_scene() { + use effectcraft_engine::project::{Footage, FootageKind, ItemKind, LayerId, LayerSource}; + let (mut a, plate, uid) = app(); + let cid = a.session.active_comp_id().unwrap(); + a.session + .edit("video fixture", None, |p, _| { + let item = p.add_item( + "Video", + Default::default(), + None, + ItemKind::Footage(Footage { + path: "/tmp/effectcraft-ui-synthetic.mp4".into(), + kind: FootageKind::Video, + width: W as u32, + height: H as u32, + has_video: true, + duration: effectcraft_engine::time::Tick::from_seconds_f64(2.0), + ..Default::default() + }), + ); + p.comp_mut(cid).unwrap().layer_mut(LayerId(plate)).unwrap().source = LayerSource::Footage { item }; + Ok(()) + }) + .unwrap(); + a.session.camera_pending.clear(); + let output = std::env::temp_dir().join(format!("effectcraft-ui-blender-{}.py", std::process::id())); + let selected_path = output.clone(); + a.hooks.pick_save_file = Some(Box::new(move |name, ext| { + assert!(name.ends_with(".py")); + assert_eq!(ext, "py"); + Some(selected_path.to_string_lossy().into_owned()) + })); + let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let builder = if shot.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::EffectControls); + h.run_steps(5); + let button = rect(&h, &format!("effectControls.cameraTracker.{uid}.exportBlender")); + if let Some(path) = shot { + h.render().expect("render export button").save(path).unwrap(); + } + click(&mut h, button.center(), Default::default()); + assert!(output.exists(), "UI export failed: {}", h.state().ui.status); + let script = std::fs::read_to_string(output).expect("clicking the export button saves a Python scene"); + assert!(script.contains("def import_scene():")); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project"); +} + /// Drag with `button` from `from` to `to` in steps, then release. fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { h.input_mut().events.push(egui::Event::PointerMoved(from)); @@ -171,8 +226,8 @@ fn viewer_zooms_and_pans_over_the_track_points() { let zoom = h.state().ui.viewer.zoom; h.input_mut().events.push(egui::Event::PointerMoved(at)); h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 0b0b30720..9e71f8fd7 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -83,3 +83,23 @@ fn a_preset_name_cannot_be_a_built_in_one() { assert!(h.state().auto.find("dialog.comp.presetName").is_none()); assert!(preset_names(&h).is_empty()); } + +/// Cancel and OK stay where they are when switching between Basic, Advanced and 3D Renderer, +/// at any interface scale: the tabs' heights are measured, not assumed. +#[test] +fn composition_settings_buttons_stay_put_between_tabs() { + for zoom in [1.0, 1.3] { + let mut h = harness(); + h.ctx.set_zoom_factor(zoom); + h.run_steps(3); + open_settings(&mut h); + let rects = |h: &Harness<'_, EffectcraftApp>| { + ["dialog.comp.ok", "dialog.comp.cancel", "dialog.comp.name"].map(|id| h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect) + }; + let basic = rects(&h); + for tab in ["advanced", "renderer", "basic"] { + click(&mut h, &format!("dialog.comp.tab.{tab}")); + assert_eq!(rects(&h), basic, "{tab} at {zoom}x"); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_dialog_preview.rs b/crates/ui-egui/tests/it/ui_dialog_preview.rs new file mode 100644 index 000000000..ce5f2803e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_dialog_preview.rs @@ -0,0 +1,303 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId, Resolution}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The Resolution is no part of the Composition Settings command, so a preview never applies it: +/// OK has to, also when it keeps a preview of another field. +#[test] +fn composition_settings_ok_after_a_preview_still_applies_the_resolution() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the preview applied the width"); + h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 80); + assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution"); +} + +/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the +/// project up to the draft it finds instead of keeping the older preview. +#[test] +fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..5608dfcf3 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -124,6 +124,44 @@ fn alt_dragging_a_project_item_replaces_the_layer_source() { assert_eq!(layer.name, "Top", "the layer keeps its name"); } +/// Alt/Option-dropping a Project item on the Composition viewer replaces the source of the layer +/// under the pointer, as in the Timeline, instead of adding a layer (#2173). +#[test] +fn alt_dropping_a_project_item_on_the_viewer_replaces_the_layer_source() { + use effectcraft_engine::project::{ItemId, LayerId, LayerSource}; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + // The three solids overlap completely, so the topmost one is under the pointer. + let (top, middle, bottom) = (layer_id(&h, "Top"), layer_id(&h, "Middle"), layer_id(&h, "Bottom")); + let source = |h: &Harness<'_, EffectcraftApp>, id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().source.clone(); + let (middle_before, bottom_before) = (source(&h, middle), source(&h, bottom)); + let before = stack(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + assert_eq!(source(&h, top), LayerSource::Comp { item: ItemId(clip) }, "the layer under the pointer takes the new source"); + assert_eq!(source(&h, middle), middle_before, "the layer below it keeps its source"); + assert_eq!(source(&h, bottom), bottom_before, "the bottom layer keeps its source"); +} + +/// A locked layer under the pointer refuses the Alt-drop; the layer behind it is not replaced +/// instead (#2173). +#[test] +fn alt_dropping_on_a_locked_layer_in_the_viewer_replaces_nothing() { + use effectcraft_engine::project::LayerId; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let top = layer_id(&h, "Top"); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [top], "switch": "locked", "value": true})).unwrap(); + h.run_steps(3); + let sources = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.source.clone()).collect::>(); + let before = sources(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(sources(&h), before, "no layer takes the new source"); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(top)).unwrap().switches.locked); +} + /// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel /// selection (#991). #[test] @@ -198,7 +236,7 @@ fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); drag(&mut h, from, to, Modifiers::NONE); assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); - for layer in &h.state().session.active_comp().unwrap().layers[..2] { + for layer in h.state().session.active_comp().unwrap().layers.iter().take(2) { let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); @@ -594,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() { let _ = std::fs::remove_dir_all(&dir); } +/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file +/// (through the Import Image Sequence question a numbered still asks) and makes a composition +/// of it, as in After Effects. +#[test] +fn new_composition_from_footage_makes_a_comp_of_the_picked_file() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let dir = frames("fromfootage", 2); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + click_id(&mut h, "viewer.empty.import"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (64, 32)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["frame_0002.png"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret /// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in /// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rs new file mode 100644 index 000000000..e88f0a2a4 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs @@ -0,0 +1,84 @@ +//! The Environment light through the app's GPU compositor (egui_kittest with wgpu): adding one +//! visibly changes the viewer's GPU frames (#2436). Skips without a GPU adapter (CI). + +use effectcraft_engine::Session; +use effectcraft_engine::project::Renderer; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +use crate::ui_gpu::wait_for_compositor; + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +/// egui_kittest's wgpu setup on the adapter the headless compositor picked, so the viewer's GPU +/// compositor attaches (see ui_gpu.rs). +fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let fallback = c.native_adapter_selector.take(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name; + match (adapters.iter().find(named), &fallback) { + (Some(a), _) => Ok(a.clone()), + (None, Some(f)) => f(adapters, surface), + (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()), + } + })); + } + setup +} + +/// A red Environment light at full intensity lights the scene on the GPU compositor too: the +/// viewer's GPU frames change when the light is added. +#[test] +fn environment_light_lights_the_scene_on_the_gpu() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + eprintln!("headless compositor adapter: {adapter}"); + let gl = adapter.ends_with("(Gl)"); + drop(probe); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .wgpu_setup(same_adapter(adapter)) + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached, so the + // frames compared below are GPU frames, not ones the CPU composited meanwhile (see ui_gpu.rs). + wait_for_compositor(&mut h); + if gl && h.state().gpu_adapter().is_none() { + eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); + return; + } + assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); + let s = &mut h.state_mut().session; + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + settle(&mut h); + let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("GPU frame after adding the light").pixels.clone(); + + assert_eq!(before.len(), after.len()); + let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count(); + assert!(diff > 500, "a red Environment light at 100 % changed only {diff} GPU pixels"); +} diff --git a/crates/ui-egui/tests/it/ui_expression_values.rs b/crates/ui-egui/tests/it/ui_expression_values.rs new file mode 100644 index 000000000..b797e6cf6 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_expression_values.rs @@ -0,0 +1,296 @@ +//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression +//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars, +//! every part of a vector (with its separator) and both parts of an angle (with its sign), hovered +//! and dragged too, in dark and light themes. A disabled, removed or empty expression shows the +//! ordinary colour again. The tests read the colour the value text is painted with (the frame's +//! text shapes: the numbers are painted with an override colour, the separator and sign as plain +//! text), and require every expected part to be there. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::theme::ThemeKind; +use egui::epaint::Shape; +use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const TIMELINE: &str = "timeline.prop"; +const EFFECT_CONTROLS: &str = "effectControls.prop"; +const PROPERTIES: &str = "properties.prop"; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Two components (X, Y). + Vector, + /// Revolutions, sign and degrees. + Angle, + Scalar, +} + +/// A property whose value the tests check, and the panels that must show it. +#[derive(Clone, Copy, Debug)] +struct Checked { + uid: u64, + kind: Kind, + panels: &'static [&'static str], +} + +/// A comp with one solid and the Transform effect on it. +struct Ids { + layer: u64, + /// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform + /// effect's Position, Rotation and Opacity (Effect Controls). + driven: [Checked; 6], + /// Transform Scale: no expression. + scale: Checked, +} + +fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "Transform"})).unwrap()["effects"][0].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let g: &PropGroup = layer.props.find_group(fx).unwrap(); + let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + const EFFECT: &[&str] = &[EFFECT_CONTROLS]; + let c = |uid, kind, panels| Checked { uid, kind, panels }; + let ids = Ids { + layer: l, + driven: [ + c(uid("transform/position"), Kind::Vector, LAYER), + c(uid("transform/rotation"), Kind::Angle, LAYER), + c(uid("transform/opacity"), Kind::Scalar, LAYER), + c(fuid("position"), Kind::Vector, EFFECT), + c(fuid("rotation"), Kind::Angle, EFFECT), + c(fuid("opacity"), Kind::Scalar, EFFECT), + ], + scale: c(uid("transform/scale"), Kind::Vector, LAYER), + }; + let transform = layer.props.sub("transform").unwrap().uid; + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.ui.timeline.open_layers.insert(l); + app.ui.timeline.open_groups.insert(transform); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, ids) +} + +fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: &serde_json::Value) { + let mut p = p.clone(); + p["layer"] = json!(ids.layer); + p["prop"] = json!(prop); + h.state_mut().session.execute("prop.setExpression", p).unwrap(); +} + +fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// The area of `c`'s value in `panel`, every expected part required: both components of a vector, +/// the revolutions and degrees of an angle (the separator and sign lie between them). +fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect { + let uid = c.uid; + match c.kind { + Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))), + Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))), + Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")), + } +} + +/// The text painted inside `area` in the last frame, with its colour. +fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> { + fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)), + Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => { + out.push((t.galley.text().trim().to_string(), t.override_text_color.unwrap_or(t.fallback_color))); + } + _ => {} + } + } + let mut out = vec![]; + for c in &h.output().shapes { + walk(&c.shape, area, &mut out); + } + out +} + +/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the +/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar. +fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) { + let shown = texts(h, value_area(h, panel, c)); + let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count(); + let has = |text: &str| shown.iter().any(|(s, _)| s == text); + let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid); + match c.kind { + Kind::Vector => { + assert!(numbers >= 2, "{ctx}: both components"); + if panel == TIMELINE { + assert!(has(","), "{ctx}: the separator"); + } + } + Kind::Angle => { + assert!(numbers >= 2, "{ctx}: revolutions and degrees"); + assert!(has("+") || has("-"), "{ctx}: the sign"); + } + Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"), + } + assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}"); +} + +/// Every checked property in every panel it belongs to. +fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) { + for c in checked { + for panel in c.panels { + assert_value(h, panel, *c, want, what); + } + } +} + +/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue. +#[test] +fn expression_driven_values_are_red_in_every_panel() { + let (mut h, ids) = setup(); + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_ne!(t.expr_text, t.hot_text); + assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); + } +} + +/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of +/// every value is checked again after each change, in both themes. +#[test] +fn expression_state_changes_switch_the_colour() { + let (mut h, ids) = setup(); + let states = [ + (json!({"expression": "value"}), true, "enabled"), + (json!({"enabled": false}), false, "disabled"), + (json!({"enabled": true}), true, "enabled again"), + (json!({"expression": ""}), false, "removed"), + (json!({"expression": "value"}), true, "set again"), + (json!({"expression": " "}), false, "whitespace only"), + ]; + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for (p, red, what) in &states { + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, p); + } + h.run_steps(3); + let want = if *red { t.expr_text } else { t.hot_text }; + assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale")); + } + } +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); +} + +/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red, +/// in every panel and both themes. +#[test] +fn a_hovered_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for c in &opacity { + for panel in c.panels { + let area = value_area(&h, panel, *c); + hover(&mut h, area.center()); + let at = h.ctx.input(|i| i.pointer.hover_pos()); + assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value"); + assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered")); + } + } + // An ordinary value does take the hover accent. + let area = value_area(&h, TIMELINE, ids.scale); + let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center(); + hover(&mut h, x); + let shown = texts(&h, area); + assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}"); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the +/// Timeline and Effect Controls. +#[test] +fn a_dragged_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + let t = h.state().tokens; + for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] { + let at = value_area(&h, panel, c).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0))); + h.step(); + } + assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on"); + assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged")); + let end = at + vec2(12.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Separate Dimensions: X Position and Y Position are separate scalars, so an expression on X Position +/// turns only X red, in the Timeline and Properties panel and both themes; Y Position stays blue. +#[test] +fn separated_position_colours_each_dimension_on_its_own() { + let (mut h, ids) = setup(); + h.state_mut().session.execute("prop.separateDimensions", json!({"layer": ids.layer, "value": true})).unwrap(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| { + let s = &h.state().session; + s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid + }; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + let x = Checked { uid: uid(&h, "transform/positionX"), kind: Kind::Scalar, panels: LAYER }; + let y = Checked { uid: uid(&h, "transform/positionY"), kind: Kind::Scalar, panels: LAYER }; + set_expression(&mut h, &ids, x.uid, &json!({"expression": "value"})); + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_all(&h, &[x], t.expr_text, &format!("{theme:?} X Position (expression)")); + assert_all(&h, &[y], t.hot_text, &format!("{theme:?} Y Position (none)")); + } +} diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 122003abe..77984cf6b 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -106,6 +106,52 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { assert_eq!(doc.style_at(8).font, "Inter"); } +/// A font file of just `name`: the family and Regular in English, and the family again in +/// Simplified Chinese (language 0x804). +fn font_with_native_name(family: &str, native: &str) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let records = [(0x409, 1, family), (0x409, 2, "Regular"), (0x804, 1, native)]; + let strings: Vec> = records.iter().map(|(_, _, s)| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); + let mut name = u16s(&[0, records.len() as u16, 6 + 12 * records.len() as u16]); + let mut off = 0; + for ((lang, id, _), st) in records.iter().zip(&strings) { + name.extend(u16s(&[3, 1, *lang, *id, st.len() as u16, off])); + off += st.len() as u16; + } + strings.iter().for_each(|st| name.extend(st)); + let mut out = u16s(&[1, 0, 1, 0, 0, 0]); + out.extend(b"name"); + out.extend([0u8; 4]); + out.extend(28u32.to_be_bytes()); + out.extend((name.len() as u32).to_be_bytes()); + out.extend(name); + out +} + +/// A family whose own-language name has a character no interface font has is listed under its +/// English name: the harness fails on a missing glyph, and the app would draw boxes. Picking the +/// row still applies the family. +#[test] +fn font_menu_lists_a_name_the_interface_fonts_lack_in_english() { + const FAMILY: &str = "Native Name Menu Test"; + // U+F1234 is in a private use plane that no interface font maps. + const NATIVE: &str = "中文\u{F1234}字体"; + assert_eq!(effectcraft_engine::text::fonts::add_font_data(font_with_native_name(FAMILY, NATIVE)).len(), 1); + assert_eq!(effectcraft_engine::text::fonts::native_families().get(FAMILY).map(String::as_str), Some(NATIVE)); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Native", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + type_text(&mut h, "native name menu"); + let shown: Vec = h.state().auto.query("character.font.option.").iter().map(|e| e.id.clone()).collect(); + assert_eq!(shown, vec![format!("character.font.option.{FAMILY}")]); + click(&mut h, &format!("character.font.option.{FAMILY}")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, FAMILY); +} + /// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at /// 400: a variable font as far as the font menu's filter is concerned. fn pinned_variable_font(family: &str) -> Vec { @@ -210,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() { click(&mut h, "character.font.option.JetBrains Mono"); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index 355c35bf5..643d99492 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -6,10 +6,11 @@ use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; +/// Step until the GPU compositor's worker is done and a frame has landed in the viewer. fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..600 { + for _ in 0..3000 { h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + if !h.state().gpu_initializing() && h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { break; } std::thread::sleep(std::time::Duration::from_millis(20)); @@ -19,6 +20,117 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Step until the GPU compositor's worker has handed its result over (attached, or why not). +/// The first step starts the build. +pub(crate) fn wait_for_compositor(h: &mut Harness<'_, EffectcraftApp>) { + h.step(); + for _ in 0..3000 { + if !h.state().gpu_initializing() { + return; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the GPU compositor's worker gave no result within a minute"); +} + +/// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners +/// without a GPU skip). +fn any_adapter() -> bool { + let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle()); + !pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all())).is_empty() +} + +fn small_session() -> Session { + let mut s = Session::default(); + let comp = s.execute("comp.new", json!({"name": "First", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"comp": comp, "color": "#ff0000"})).unwrap(); + s +} + +/// The window opens before the GPU compositor exists: its pipelines compile on a worker thread +/// (tens of seconds on a cold shader cache), and until then the interface draws, says so, and +/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable. +#[test] +fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let (release, gate) = std::sync::mpsc::channel::<()>(); + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + // The status line's 5 s expiry reads the app clock: a manual one makes the steps below + // stand for a known span of time. + app.use_manual_clock(); + app.hooks.before_gpu_build = Some(Box::new(move || { + let _ = gate.recv(); + })); + app + }); + // The first frames paint, and a CPU frame reaches the viewer, while the worker still waits. + for _ in 0..600 { + h.step(); + if h.state().is_ready() && !h.output().shapes.is_empty() && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().gpu_initializing(), "the compositor is still being built"); + assert!(!h.output().shapes.is_empty(), "the interface draws before the compositor exists"); + assert!(h.state().session.accel.is_none(), "nothing renders on a compositor that doesn't exist yet"); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line is honest about it"); + assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING)); + assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame"); + // Other statuses clear after 5 s; this one stays for as long as the build takes (each step + // is a quarter of a second). + for _ in 0..30 { + h.state_mut().clock_mut().advance(0.25); + h.step(); + } + assert!(h.state().gpu_initializing()); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line still says so 7.5 s later"); + release.send(()).unwrap(); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert_ne!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the preparing status goes away"); + match h.state().gpu_adapter() { + Some(adapter) => { + eprintln!("compositor attached after the first frames: {adapter}"); + assert!(h.state().session.accel.is_some()); + assert!(h.state().session.accel_note.is_none()); + } + // An adapter that can't run the compositor (software GL): the reason is recorded. + None => assert!(h.state().session.accel_note.as_deref().is_some_and(|n| n != effectcraft_ui_egui::GPU_PREPARING)), + } +} + +/// A graphics backend that panics while the compositor's pipelines compile panics on the worker +/// thread, not under the UI: the window keeps drawing on the CPU and says why. +#[test] +fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(|| panic!("synthetic shader compiler panic"))); + app + }); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert!(h.state().gpu_adapter().is_none()); + assert!(h.state().session.accel.is_none()); + let note = h.state().session.accel_note.clone().unwrap_or_default(); + assert!(note.contains("synthetic shader compiler panic"), "{note}"); + assert!(!h.output().shapes.is_empty(), "the interface still draws"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows a CPU frame"); +} + /// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`), /// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on /// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the @@ -50,21 +162,24 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. let gl = adapter.ends_with("(Gl)"); drop(probe); - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - s.execute("time.set", json!({"time": 2.0})).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1400.0, 900.0)) .with_pixels_per_point(1.0) .wgpu_setup(same_adapter(adapter)) - .build_eframe(|_| EffectcraftApp::new(s)); - settle(&mut h); + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached. A frame the + // CPU composited while it was being built has the GPU frame's key, so the viewer would keep + // showing it, and the comparison below would be the CPU against itself. + wait_for_compositor(&mut h); if gl && h.state().gpu_adapter().is_none() { eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); return; } assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute("file.openDemoProject", json!({})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 2.0})).unwrap(); + settle(&mut h); let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame"); let gpu_shot = h.render().expect("render"); // Mercury Software Only: the same frame from the CPU. diff --git a/crates/ui-egui/tests/it/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs index fa8c7b73f..f5ee8634b 100644 --- a/crates/ui-egui/tests/it/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/it/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,98 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds. +#[test] +fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops"); + h.state_mut().gradient_draft_mut().add(Track::Color, 0.75); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx); + assert_eq!(h.state().dialog, None); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found"); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] diff --git a/crates/ui-egui/tests/it/ui_inline_edit.rs b/crates/ui-egui/tests/it/ui_inline_edit.rs index c9cf4693e..6c29045bb 100644 --- a/crates/ui-egui/tests/it/ui_inline_edit.rs +++ b/crates/ui-egui/tests/it/ui_inline_edit.rs @@ -634,6 +634,74 @@ fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() { assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25")); } +/// A primary-button drag from `from` to `to` with `modifiers` held from the press to the release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: with Alt held as it drops, a pick whip refers to an effect's property by its index, +/// `effect("Slider Control")(1)`, as in After Effects; without Alt, by its name. Onto Effect +/// Controls or the Timeline, and into the expression being edited (with a dimension). +#[test] +fn an_alt_pick_whip_refers_to_an_effect_property_by_index() { + let (mut h, _) = harness(); + let (layer, opacity, transform, effects) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let props = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.clone(); + let effects: Vec = std::iter::once(props.sub("effects").unwrap().uid).chain(props.sub("effects").unwrap().groups().map(|g| g.uid)).collect(); + (l, props.sub("transform").unwrap().get("opacity").unwrap().uid, props.sub("transform").unwrap().uid, effects) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().ui.timeline.open_groups.extend(effects); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let (slider, point) = (props(&h).prop("effects/#1/slider").unwrap().uid, props(&h).prop("effects/#2/point").unwrap().uid); + let expr = |h: &Harness<'_, EffectcraftApp>| props(h).find(opacity).unwrap().expr.clone().map(|e| e.text); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let whip = |h: &mut Harness<'_, EffectcraftApp>, target: &str, modifiers: Modifiers| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.propertyPickWhip")), center(h, target)); + drag_with(h, from, to, modifiers); + }; + whip(&mut h, &format!("effectControls.prop.{slider}.value"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto Effect Controls"); + let undo = h.state().session.history.undo.len(); + whip(&mut h, &format!("effectControls.prop.{slider}.value"), Modifiers::NONE); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(\"Slider\")"), "without Alt, by name"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); + whip(&mut h, &format!("timeline.prop.{slider}.name"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto the Timeline"); + // Into the expression being edited, at the cursor, with the value it was dropped on. + let p = center(&h, &format!("timeline.prop.{opacity}.expression")); + click(&mut h, p, 1); + key(&mut h, Key::End); + type_text(&mut h, " + "); + let (from, to) = (center(&h, &format!("timeline.prop.{opacity}.pickWhip")), center(&h, &format!("effectControls.prop.{point}.value.1"))); + drag_with(&mut h, from, to, alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1) + effect(\"Point Control\")(1)[1]")); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "undone in one step"); +} + /// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X /// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so /// they stacked on top of it and seemed to disappear. diff --git a/crates/ui-egui/tests/it/ui_keyframes.rs b/crates/ui-egui/tests/it/ui_keyframes.rs index b47963d85..b137a06fc 100644 --- a/crates/ui-egui/tests/it/ui_keyframes.rs +++ b/crates/ui-egui/tests/it/ui_keyframes.rs @@ -105,11 +105,15 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { _ => None, }) .collect(); - assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + // The system clipboard has the key as After Effects keyframe text (for other applications), + // so it isn't empty and Ctrl+V sends a paste event. + let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; + assert!(text.starts_with("Adobe After Effects 9.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t30\t100\t\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); - h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.input_mut().events.push(Event::Paste(text.clone())); h.run_steps(2); assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); } @@ -190,8 +194,18 @@ fn copy_and_paste_key_subsets_across_position_rotation_and_scale() { assert_eq!(h.state().session.state.selected_props.len(), 3); h.input_mut().events.push(Event::Copy); h.step(); - let note = format!("EffectCraft: {count} keyframes"); - assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e))); + let note = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .unwrap(); + let rows = note.lines().filter(|line| line.strip_prefix('\t').is_some_and(|row| row.starts_with(|c: char| c.is_ascii_digit()))).count(); + assert_eq!(rows, count, "one row per copied key: {note}"); h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0)); let undo_steps = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Paste(note)); diff --git a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs index 1aabdd2bc..73ed95cbd 100644 --- a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs +++ b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs @@ -107,8 +107,8 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { let at = canvas.min + canvas.size() * 0.25; h.input_mut().events.push(Event::PointerMoved(at)); h.input_mut().events.push(Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, @@ -176,3 +176,33 @@ fn roto_brush_in_the_composition_panel_says_where_it_works() { assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); assert_eq!(h.state().ui.focused, PanelKind::Layer); } + +/// A two-finger trackpad scroll pans the layer with any tool and a pinch zooms around the pointer. +#[test] +fn trackpad_scroll_pans_and_pinch_zooms_around_the_pointer() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + let at = canvas.min + canvas.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(60.0, -30.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + let panned = rect(&h, "layerPanel.canvas"); + let moved = panned.min - canvas.min; + assert!((moved.x - 60.0).abs() < 1.5 && (moved.y + 30.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((panned.width() - canvas.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", canvas.width(), panned.width()); + // Pinch: zooms, keeping the point under the pointer. + let at = panned.min + panned.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "layerPanel.canvas"); + assert!((zoomed.width() / panned.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", panned.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index fe1392be1..aac090802 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,13 +177,33 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { invoke(&mut h, "layer.newLight", json!({})); for k in LightKind::ALL { choose(&mut h, k); - let shown = !matches!(k, LightKind::Ambient | LightKind::Environment); - for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] { - assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label()); + // Falloff is distance attenuation: image-based Environment lights and Ambient have none. + let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); + assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + // Cast Shadows shows for every light but Ambient, Environment included (#2436). + let shadows = k != LightKind::Ambient; + for label in ["Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the +/// engine stores on the layer. +#[test] +fn an_environment_light_takes_cast_shadows_from_the_dialog() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + click_id(&mut h, "dialog.light.casts"); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + let l = layer(&h, id); + assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer"); + assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none()); +} + #[test] fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/ui_marker_names.rs b/crates/ui-egui/tests/it/ui_marker_names.rs new file mode 100644 index 000000000..99adf41f3 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_marker_names.rs @@ -0,0 +1,97 @@ +//! A marker's name is drawn in a small outlined box beside its flag, in the theme's own field colours (egui_kittest, a real +//! render): the fill and the outline follow Dark and Light, and the two themes differ. `MARKER_NAMES_SNAPSHOT=/abs/dir` +//! also saves the rendered panel for each theme. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::theme::ThemeKind; +use egui_kittest::Harness; +use serde_json::json; + +/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders. +static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn close(got: [u8; 4], want: egui::Color32) -> bool { + got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) +} + +#[test] +fn the_name_box_follows_the_theme() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse"})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(4); + let ctx = h.ctx.clone(); + + let mut seen = vec![]; + for (kind, name) in [(ThemeKind::Dark, "dark"), (ThemeKind::Light, "light")] { + h.state_mut().set_theme(&ctx, kind); + h.run_steps(4); + let tokens = h.state().tokens; + assert_eq!(tokens.kind, kind); + // the flag's hit area is 10 wide, centred on the marker; the box starts 6 points right of the centre + let hit = h.state().auto.find("timeline.marker.0").unwrap_or_else(|| panic!("the marker is on screen ({name})")).rect; + let (x, y0) = (hit[0] + hit[2] / 2.0, hit[1] + 1.0); + let h_flag = (hit[3] - 1.0).min(10.0); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join(format!("marker-name-{name}.png"))).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + h_flag / 2.0; + // inside the box, left of the text: the field fill; on its left edge: the field outline + let fill = at(x + 6.0 + 2.5, mid); + let edge = at(x + 6.0 + 0.5, mid); + assert!(close(fill, tokens.field_bg), "{name}: box fill {fill:?}, the theme's field_bg is {:?}", tokens.field_bg); + assert!(close(edge, tokens.field_border), "{name}: box outline {edge:?}, the theme's field_border is {:?}", tokens.field_border); + seen.push((fill, edge)); + } + assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey"); +} + +/// A duration shorter than the name box ends where the box would sit. The box steps past the end cap, so the duration +/// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it. +#[test] +fn a_short_duration_stays_visible_beside_the_name_box() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse", "duration": 0.1})).unwrap(); + // a second marker half a second later gives the timeline's scale (points per second) + s.execute("markers.set", json!({"new": true, "time": 1.5})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(5); + let tokens = h.state().tokens; + let centre = |id: &str| { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is on screen")).rect; + (r[0] + r[2] / 2.0, r[1] + 1.0, (r[3] - 1.0).min(10.0)) + }; + let ((xa, y0, flag_h), (xb, _, _)) = (centre("timeline.marker.0"), centre("timeline.marker.1")); + let per_second = (xb - xa) / 0.5; + let xe = xa + 0.1 * per_second; + assert!(xe - xa < 30.0, "the duration is shorter than the name box: {} points", xe - xa); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join("marker-short-duration.png")).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + flag_h / 2.0; + // the end cap: a pixel in the marker's colour (new markers are red; the theme's field fill is neutral) within a point + // of where the duration ends, which the box would otherwise cover + let cap = (-1..=1).map(|d| at(xe + d as f32, mid)).max_by_key(|p| i32::from(p[0]) - i32::from(p[1])).unwrap(); + assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the end cap is drawn: {cap:?}"); + // the bar shows left of the cap, not the box's fill + let bar = at(xe - 4.0, mid); + assert!(i32::from(bar[0]) - i32::from(bar[1]) > 20, "the bar shows left of the end cap, not the box: {bar:?}"); + // the box starts after the cap + let fill = at(xe + 3.0 + 2.5, mid); + assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg); +} diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index c76f7fafd..8501f6b52 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -65,6 +65,47 @@ fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { h.query_by_label_contains(label).is_some() } +#[test] +fn browse_presets_clears_search_and_scrolls_to_the_visible_presets_list() { + use effectcraft_ui_egui::dock::PanelKind; + + let mut h = harness(); + let ctx = h.ctx.clone(); + let favorites: Vec = effectcraft_engine::effects::all().iter().map(|effect| effect.id.to_string()).collect(); + assert!(!favorites.is_empty(), "the test needs registered effects"); + let recent: Vec = favorites.iter().take(8).cloned().collect(); + + h.state_mut().session.prefs.effects_favorites = favorites; + h.state_mut().ui.effects_recent = recent; + h.state_mut().ui.effects_open.extend(["favorites".into(), "recent".into()]); + h.state_mut().ui.effects_search = "no matching animation preset".into(); + h.state_mut().ui.effects_view.presets = true; + + let managed = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.manage", json!({})); + assert!(managed.is_ok(), "{managed:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert_eq!(h.state().ui.effects_search, "no matching animation preset", "Manage Effects keeps its existing search behavior"); + assert!(h.state().ui.effects_view.presets, "Manage Effects keeps the existing presets setting"); + + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets").is_none(), "the unmatched search hides Animation Presets"); + h.state_mut().ui.effects_view.presets = false; + + let result = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.browsePresets", json!({})); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert!(h.state().ui.effects_search.is_empty()); + assert!(h.state().ui.effects_view.presets); + assert!(h.state().ui.effects_open.contains("presets")); + + h.run_steps(3); + assert!( + h.state().auto.find("effects.category.presets").is_some(), + "Animation Presets must be visible after Browse Presets, even below expanded Favorites and Recently Used" + ); +} + /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the /// preview behind the open menu. #[test] @@ -224,10 +265,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { std::fs::remove_dir_all(dir).unwrap(); } -/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and -/// Avid), which the Media Browser lists and the importer reads. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -238,11 +279,15 @@ fn import_dialog_offers_mxf() { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); - assert!(result.is_null(), "cancelled: nothing imported"); - let offered = offered.borrow(); - assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); - assert!(offered.iter().any(|e| e == "mov")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews @@ -373,3 +418,63 @@ fn outside_presses_and_focus_loss_close_the_menus() { h.run_steps(2); assert!(!shown(&h, "Open Project"), "losing focus closes the menus"); } + +/// From an open submenu, the pointer reaching another title switches menus too (#1290). +#[test] +fn hovering_a_title_from_an_open_submenu_switches_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + let new = h.query_by(|n| n.label().is_some_and(|l| l.trim_end_matches('\u{23f5}').trim() == "New")).expect("File ▸ New").rect(); + move_to(&mut h, new.center()); + h.run_steps(5); + assert!(shown(&h, "New Project from Template"), "the New submenu is open"); + move_to(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "hovering Edit opened it"); + assert!(!shown(&h, "New Project from Template") && !shown(&h, "Open Project"), "and closed File and its submenu"); +} + +/// A title the pointer can't reach (the app is disabled under a dialog) doesn't switch menus +/// (#1290). +#[test] +fn a_disabled_title_does_not_switch_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); + h.run_steps(2); + move_to(&mut h, edit.center()); + assert!(!shown(&h, "Keyboard Shortcuts"), "Edit didn't open"); +} + +/// A pointer resting on a title doesn't undo Left / Right: only a moving pointer switches +/// (#1290). +#[test] +fn a_resting_pointer_does_not_override_the_arrow_keys() { + if cfg!(target_os = "macos") { + // (Right / Left between menus are tested where the in-window bar is the app's menu.) + return; + } + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + key(&mut h, Key::ArrowRight); + h.run_steps(3); + assert!(shown(&h, "Keyboard Shortcuts"), "Right opened Edit with the pointer still on File"); + assert!(!shown(&h, "Open Project")); +} + +/// Leaving the window keeps the menu open, and coming back in over another title switches to it +/// (#1290): egui reports no pointer movement for that first position. +#[test] +fn pointer_reentering_over_another_title_switches_menus() { + let mut h = harness(); + let (file, edit) = (rect(&h, "menu.File"), rect(&h, "menu.Edit")); + click_at(&mut h, file.center()); + h.input_mut().events.push(Event::PointerGone); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "leaving the window kept File open"); + move_to(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "coming back over Edit opened it"); + assert!(!shown(&h, "Open Project"), "and closed File"); +} diff --git a/crates/ui-egui/tests/it/ui_new_primitive.rs b/crates/ui-egui/tests/it/ui_new_primitive.rs index fbb3a34e5..c40aa23ed 100644 --- a/crates/ui-egui/tests/it/ui_new_primitive.rs +++ b/crates/ui-egui/tests/it/ui_new_primitive.rs @@ -70,7 +70,7 @@ fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { } fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { - h.state().session.active_comp().unwrap().layers.clone() + h.state().session.active_comp().unwrap().layers.to_vec() } fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..6d06c6bf7 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() { assert_eq!(fx.name, "Radial Wipe"); assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); } + +/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value +/// applies once the button is released. +#[test] +fn ui_scale_applies_when_the_drag_ends() { + let mut h = harness(); + open_settings(&mut h); + let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0))); + h.step(); + assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag"); + } + h.input_mut().events.push(Event::PointerButton { + pos: from + egui::vec2(80.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(4); + let (pct, zoom) = scale(&h); + assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0)); +} diff --git a/crates/ui-egui/tests/it/ui_project_delete.rs b/crates/ui-egui/tests/it/ui_project_delete.rs index f217c29db..2a395cf4b 100644 --- a/crates/ui-egui/tests/it/ui_project_delete.rs +++ b/crates/ui-egui/tests/it/ui_project_delete.rs @@ -364,3 +364,29 @@ fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); } } + +/// An open text edit stands down the Project-panel Clear redirect: the composition being typed +/// into must not be deleted out from under it, with no confirmation when nothing uses it (#2227). +#[test] +fn clear_with_the_project_focused_stands_down_during_a_text_edit() { + let (mut h, _, _) = harness(); + let working = h.state().session.active_comp_id().unwrap(); + // the composition being typed into is also the Project panel's selection, as when it was opened + click(&mut h, &format!("project.item.{}.name", working.0), Modifiers::NONE); + let text = h.state_mut().session.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": text, "created": true})).unwrap(); + h.state_mut().ui.focused = PanelKind::Project; + h.run_steps(2); + assert!(h.state().session.state.project_selection.contains(&working), "the comp is the project selection"); + let project = h.state().session.project.clone(); + + // Edit > Clear (the menu, or the control channel) dispatches through `invoke` + let ctx = h.ctx.clone(); + let cleared = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})); + h.run_steps(3); + + cleared.expect("Clear stands down during a text edit"); + assert!(h.state().session.project.item(working).is_some(), "the composition being typed into survives"); + assert_eq!(h.state().session.project, project, "nothing was deleted"); + assert!(h.state().session.state.text_edit.is_some(), "the text edit stays open"); +} diff --git a/crates/ui-egui/tests/it/ui_project_rows.rs b/crates/ui-egui/tests/it/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/it/ui_project_rows.rs +++ b/crates/ui-egui/tests/it/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index aa439737b..0a924bb65 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] @@ -264,6 +300,79 @@ fn script_windows_send_mouse_events_to_listeners() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2584: a drag that starts on a control keeps reaching it outside the script window (moves and +/// the release), and `MouseEvent.buttons` tells a drag from a hover. +#[test] +fn a_drag_keeps_its_control_past_the_window_edge() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Drag"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var seen = []; + var rec = function (e) { seen.push(e.type + (e.clientX < 0 ? " out" : " in") + " B" + e.buttons); log.text = seen.join("|"); }; + pad.addEventListener("mousedown", rec); + pad.addEventListener("mousemove", rec); + pad.addEventListener("mouseup", rec); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "drag.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let (from, to) = (pad.min + vec2(10.0, 20.0), pad.min + vec2(-300.0, 20.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let log = h.state().session.script_ui.windows[0].root.lookup(&json!("log")).cloned().unwrap().text; + assert!(log.starts_with("mousemove in B0|mousedown in B1|"), "a hover move has no button held: {log}"); + assert!(log.contains("mousedown in B1|mousemove out B1"), "moves outside the window still arrive, with the button held: {log}"); + assert!(log.ends_with("|mouseup out B0"), "the release outside reaches the control: {log}"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + +/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked +/// ScriptUI panel gets its dock's size that way. +#[test] +fn script_windows_learn_the_size_they_are_given() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Sized"); + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [200, 20]; + w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(2); + h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap(); + let win = h.state().session.script_ui.windows[0].clone(); + assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180"); + let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}"); + assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + // A docked panel: its window is its dock's size, not its preferred size. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(4); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone(); + let dock = rect(&h, &format!("scriptui.{}", panel.id)); + assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]); + h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id)); + h.run_steps(2); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 65b429f44..5fc3d8598 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -466,3 +466,31 @@ fn open_comps_are_tabs_in_the_timeline_row_and_the_rest_are_in_its_overflow_menu assert_eq!(open_comps(&h), comps); assert!(has(&h, &format!("panel.tab.Timeline.{hidden}"))); } + +/// Whether `id` was registered in the last frame (not the one before). +fn drawn(h: &Harness<'_, EffectcraftApp>, id: &str) -> bool { + h.state().auto.elements.iter().any(|e| e.id == id) +} + +#[test] +fn an_inactive_comp_tab_shows_its_close_button_on_hover_and_closes_without_showing_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.state.open_comps = vec![ItemId(main), ItemId(pre)]; + h.run_steps(3); + let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))); + // The shown comp's × is always there; Pre's only while the pointer is over its tab. + h.event(Event::PointerMoved(pos2(800.0, 900.0))); + h.run_steps(2); + assert!(drawn(&h, "panel.tab.Timeline.close") && drawn(&h, &format!("panel.tab.Timeline.{main}.close"))); + assert!(!drawn(&h, &format!("panel.tab.Timeline.{pre}.close")), "no × on an inactive tab the pointer is not over"); + h.event(Event::PointerMoved(tp.center())); + h.run_steps(2); + let close = rect(&h, &format!("panel.tab.Timeline.{pre}.close")); + assert!(tp.contains_rect(close), "{close:?} inside {tp:?}"); + // Every comp tab keeps the ×'s room: hovering changes no width. + assert_eq!((rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))), (tm, tp)); + // Its × closes Pre's Timeline; Main stays shown. + click_n(&mut h, close.center(), 1); + assert_eq!(open_comps(&h), vec![main]); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(main))); +} diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 84a218466..ea4b56d26 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() { /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a -/// text layer clicked into being and left empty goes away. +/// text layer clicked into being and left empty stays, as it does in After Effects. #[test] fn clicking_another_panel_stops_typing_into_text() { for type_tool in [false, true] { @@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() { assert_eq!(layers(&h), 3, "the Type tool started a text layer"); let at_row = row(&h); click(&mut h, at_row, Modifiers::NONE); - assert_eq!(layers(&h), 2, "left empty, it is removed"); + assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects"); click(&mut h, at, Modifiers::NONE); } else { h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); @@ -1734,3 +1734,19 @@ fn same_caret_application_text_replacement_rejects_the_previous_context_fence() h.run_steps(1); assert_eq!(edited(&h).1, "Aがbcd"); } + +/// #2077: the Character panel's Line Join buttons set the stroke's corner style (Miter for new text). +#[test] +fn character_panel_line_join_buttons_set_the_join() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Join", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let join = |h: &Harness<'_, EffectcraftApp>| layer_doc(&h.state().session, LayerId(lid)).unwrap().line_join; + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Miter); + let bevel = rect(&h, "character.lineJoin.bevel").center(); + click(&mut h, bevel, Modifiers::NONE); + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index bc42b4461..ae1e5a17f 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -1,7 +1,8 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the -//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items -//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738). +//! layers deselecting them (#205), Mask Feather's linked values (#203) and shape Size's chain link +//! (#2489), copying shape items between shape layers (#227), and Ctrl+V of SVG markup making a shape +//! layer (#738). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -75,6 +76,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test] +fn shape_contents_add_menu_adds_wiggle_transform() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window"); + let entry = h.get_by_label("Wiggle Transform").rect(); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "wiggler"]); +} + /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test] @@ -172,6 +189,52 @@ fn mask_feather_values_are_linked_and_not_negative() { assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); } +/// #2489: Rectangle and Ellipse Path Size get the Constrain Proportions chain, off by default: +/// unlinked, W moves alone; linked, H follows W in proportion. Scale on the same layer still starts +/// linked. +#[test] +fn shape_size_chain_starts_unlinked_and_keeps_the_ratio_when_linked() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [100, 50]})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newShape", json!({"kind": "ellipse", "size": [60, 30]})).unwrap()["layer"].as_u64().unwrap(); + let uid = |s: &Session, l: u64, path: &str| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().uid; + let (size, oval, scale) = + (uid(&s, l, "contents/group/contents/rect/size"), uid(&s, o, "contents/group/contents/ellipse/size"), uid(&s, l, "transform/scale")); + let mut h = harness(s); + for (layer, props) in [(l, vec![size, scale]), (o, vec![oval])] { + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["props".into()]); + h.state_mut().ui.timeline.reveal_props.extend(props); + } + h.run_steps(2); + let value = |h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(&format!("@{uid}")).unwrap().value.components() + }; + let type_value = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, size, 0, "80"); + assert_eq!(value(&h, l, size), vec![80.0, 50.0], "a new rectangle's Size starts unlinked"); + type_value(&mut h, oval, 1, "40"); + assert_eq!(value(&h, o, oval), vec![60.0, 40.0], "a new ellipse's Size starts unlinked"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "160"); + assert_eq!(value(&h, l, size), vec![160.0, 100.0], "linked, H follows W in proportion"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "40"); + assert_eq!(value(&h, l, size), vec![40.0, 100.0], "unlinked again, H stays"); + // The ellipse's Size links the same way: 60x40 linked, W to 90 makes H 60. + click(&mut h, &format!("timeline.prop.{oval}.link")); + type_value(&mut h, oval, 0, "90"); + assert_eq!(value(&h, o, oval), vec![90.0, 60.0], "linked, the ellipse keeps its ratio too"); + type_value(&mut h, scale, 0, "50"); + assert_eq!(value(&h, l, scale)[..2], [50.0, 50.0], "Scale still starts linked"); +} + /// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's /// Contents open, Rect, Oval, Rectangle 1's uid). fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) { @@ -190,6 +253,33 @@ fn contents_names(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect() } +/// #959: every item in a shape layer's Contents has an eye switch, as in After Effects; it turns +/// the item off (the renderer skips it), undoably. The group's Transform has none. +#[test] +fn shape_contents_items_have_eye_switches() { + let (mut h, a, _, rect) = two_shape_layers(); + let enabled = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.sub("contents").unwrap().find_group(rect).unwrap().enabled + }; + let eye = format!("timeline.group.{rect}.eye"); + assert!(enabled(&h)); + click(&mut h, &eye); + assert!(!enabled(&h), "the eye turns Rectangle 1 off"); + click(&mut h, &eye); + assert!(enabled(&h), "and on again"); + click(&mut h, &eye); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(enabled(&h), "one undo step"); + // Inside the group: its path and fill have eyes, its Transform doesn't. + h.state_mut().ui.timeline.open_groups.insert(rect); + h.run_steps(3); + let group = h.state().session.active_comp().unwrap().layer(LayerId(a)).unwrap().props.find_group(rect).unwrap().clone(); + let transform = group.sub("transform").expect("a shape group has a Transform").uid; + assert!(h.state().auto.find(&format!("timeline.group.{transform}.eye")).is_none(), "no eye on Transform"); + let items = group.sub("contents").expect("the group's items").groups().map(|g| g.uid).collect::>(); + assert!(!items.is_empty() && items.iter().all(|u| h.state().auto.find(&format!("timeline.group.{u}.eye")).is_some()), "every item has an eye"); +} + #[test] fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() { let (mut h, a, b, rect) = two_shape_layers(); @@ -250,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() { let fill = fill.expect("a fill colour"); fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { for c in g.groups() { + // The rectangle's path rows would push Fill below the rows the Timeline fits; the + // eyedropper only needs Fill, so leave that group collapsed. + if c.name == "Rectangle Path 1" { + continue; + } out.push(c.uid); groups(c, out); } @@ -306,3 +401,94 @@ fn ctrl_v_pastes_svg_markup_as_a_shape_layer() { assert_eq!(c.layers.len(), 4); assert_eq!(c.layers[0].name, "Oval 2"); } + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, cancel: bool) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + if cancel { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// #2210: an effect's row drags to a new place in the layer's effect stack, as in After Effects +/// (not a marquee); Escape cancels the drag. +#[test] +fn effects_drag_to_a_new_place_in_the_timeline() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert", "Tint"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let names = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().groups().map(|g| g.name.clone()).collect() + }; + let row = |h: &Harness<'_, EffectcraftApp>, uid: u64| { + let e = h.state().auto.find(&format!("timeline.group.{uid}.name")).unwrap_or_else(|| panic!("no row for {uid}")); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + // Tint above Gaussian Blur. + let (tint, blur) = (row(&h, uids[2]), row(&h, uids[0])); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), true); + assert_eq!(names(&h), ["Gaussian Blur", "Invert", "Tint"], "Escape cancels"); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), false); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"]); + // Gaussian Blur (now second) below Invert, the last. + let (blur, invert) = (row(&h, uids[0]), row(&h, uids[1])); + drag(&mut h, blur.center(), pos2(invert.center().x, invert.max.y - 2.0), false); + assert_eq!(names(&h), ["Tint", "Invert", "Gaussian Blur"]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); +} + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +} diff --git a/crates/ui-egui/tests/it/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs index 9b48b9531..a62884f43 100644 --- a/crates/ui-egui/tests/it/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/it/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,17 +39,25 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +66,39 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} + +/// A precomp whose proxy can't be decoded: the layer says so with Collapse Transformations off +/// or on, since a proxy in use keeps the precomp on the flattened path that draws the proxy. +#[test] +fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}"); + } +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 993925b59..41cf5fe43 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -482,8 +482,9 @@ fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { let (from, to) = ([140.0, 100.0], [400.0, 300.0]); let centre = [270.0, 200.0]; - // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. - let corner = [146.0, 106.0]; + // 4 comp px inside the box's top-left corner: inside a rectangle, outside every other kind + // (a rounded corner of 20 px arcs past this point). + let corner = [144.0, 104.0]; for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { let mut h = harness(); h.state_mut().ui.tool = tool; @@ -802,6 +803,76 @@ fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() { assert_ne!(scale(&h), before, "a hidden layer's handle still scales it"); } +/// #1964: Shift-dragging a selected mask vertex keeps it to the axis it moves along most, as a +/// Shift-dragged layer does; without Shift it moves freely. +#[test] +fn shift_drag_keeps_a_mask_vertex_to_one_axis() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let vertex = format!("viewer.mask.{mask}.vertex.0"); + let before = mask_path(&h, "Plate").vertices[0]; + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::SHIFT); + let shifted = mask_path(&h, "Plate").vertices[0]; + assert!(shifted[0] > before[0], "it moved along x: {before:?} -> {shifted:?}"); + assert_eq!(shifted[1], before[1], "and not along y"); + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::NONE); + let free = mask_path(&h, "Plate").vertices[0]; + assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); +} + +/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the +/// vertex keeps moving while the pointer is held there, as in After Effects. +#[test] +fn a_vertex_held_at_the_viewer_edge_pans_the_view() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let pan0 = h.state().ui.viewer.pan; + let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center(); + let edge = pos2(2.0, from.y); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0))); + h.step(); + } + let reached = mask_path(&h, "Plate").vertices[0]; + for _ in 0..20 { + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let pan = h.state().ui.viewer.pan; + assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}"); + let after = mask_path(&h, "Plate").vertices[0]; + assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}"); + // Back inside the view, nothing pans. + let still = h.state().ui.viewer.pan; + let mid = rect(&h, "viewer.comp").center(); + drag(&mut h, mid, mid + vec2(5.0, 0.0)); + assert_eq!(h.state().ui.viewer.pan, still); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() { @@ -1718,7 +1789,7 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(h.state().session.history.undo.len(), steps + 1); // Dropping a layer where it already is changes nothing. let b = row(&h, "Box").center(); - drag(&mut h, b, b + vec2(0.0, 3.0)); + drag(&mut h, b, b + vec2(20.0, 0.0)); assert_eq!(names(&h), ["Plate", "Box"]); assert_eq!(h.state().session.history.undo.len(), steps + 1); // And back to the top. @@ -1727,6 +1798,335 @@ fn timeline_rows_drag_to_reorder_layers() { assert_eq!(names(&h), ["Box", "Plate"]); } +fn timeline_layer_order(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() +} + +fn timeline_layer_row(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Rect { + rect(h, &format!("timeline.layer.{layer}.row")) +} + +fn start_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerMoved(from + vec2(20.0, 0.0))); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "the row drag started"); +} + +fn release_timeline_row_drag(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn scroll_timeline(h: &mut Harness<'_, EffectcraftApp>, dy: f32) { + let at = rect(h, "timeline.layerMarquee").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + h.run_steps(20); +} + +/// Wheel-scroll far enough to stop drawing the row that owns the held drag, then drop at +/// the other end of the stack. The release must work without that row's Response (#2492). +fn timeline_reorder_after_scroll(down: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let count = if multiple { 2 } else { 1 }; + let moving = if down { before[..count].to_vec() } else { before[before.len() - count..].to_vec() }; + if multiple { + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + } + if !down { + scroll_timeline(&mut h, -10_000.0); + } + let origin = if down { before[0] } else { *before.last().unwrap() }; + let from = timeline_layer_row(&h, origin).center(); + // Setup fills the bounded Undo history; reserve room to count this gesture's steps. + h.state_mut().session.history.clear(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, if down { -10_000.0 } else { 10_000.0 }); + assert!(h.state().auto.find(&format!("timeline.layer.{origin}.row")).is_none(), "the origin row was culled"); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "scrolling keeps the held drag active"); + let target = if down { *before.last().unwrap() } else { before[0] }; + let row = timeline_layer_row(&h, target); + let to = row.center() + vec2(0.0, row.height() * if down { 0.4 } else { -0.4 }); + release_timeline_row_drag(&mut h, to); + + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = if down { expected.len() } else { 0 }; + expected.splice(at..at, moving); + assert_eq!(timeline_layer_order(&h), expected, "the offscreen origin still completes its drop"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one reorder, including a selected group, is one undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "release clears the drag feedback"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + + // A later, independent drag must not reuse the old gesture or selected group. + let i = if down { expected.len() - 1 } else { 0 }; + let j = if down { i - 1 } else { 1 }; + let from = timeline_layer_row(&h, expected[i]).center(); + let row = timeline_layer_row(&h, expected[j]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * if down { -0.4 } else { 0.4 })); + expected.swap(i, j); + assert_eq!(timeline_layer_order(&h), expected, "the next drag is independent"); + assert_eq!(h.state().session.history.undo.len(), steps + 2); +} + +#[test] +fn timeline_rows_drag_to_bottom_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_to_top_after_scrolling() { + for multiple in [false, true] { + timeline_reorder_after_scroll(false, multiple); + } +} + +/// An intermediate scroll must not turn the viewport's lower edge into the stack's end. +fn timeline_reorder_at_viewport_edge(below: bool, multiple: bool) { + let mut h = harness(); + for i in 0..38 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(3); + let before = timeline_layer_order(&h); + let moving = before[..if multiple { 2 } else { 1 }].to_vec(); + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(2); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, moving[0]).center(); + start_timeline_row_drag(&mut h, from); + scroll_timeline(&mut h, -200.0); + let visible = |h: &Harness<'_, EffectcraftApp>| { + before.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.layer.{id}.row")).is_some()).collect::>() + }; + // Leave space inside the viewport below the last rendered layer's midpoint. + for _ in 0..20 { + let last = timeline_layer_row(&h, *visible(&h).last().unwrap()); + if last.center().y < rect(&h, "timeline.layerMarquee").max.y - 3.0 { + break; + } + scroll_timeline(&mut h, -10.0); + } + let shown = visible(&h); + assert!(!shown.contains(&moving[0]), "the drag owner is culled"); + let last = *shown.last().unwrap(); + assert_ne!(last, *before.last().unwrap(), "layers remain below the viewport"); + let row = timeline_layer_row(&h, last); + let viewport = rect(&h, "timeline.layerMarquee"); + let y = if below { (row.center().y + row.height() * 0.4).min(viewport.max.y - 1.0) } else { row.min.y + row.height() * 0.2 }; + let to = pos2(row.center().x, y); + assert!(viewport.contains(to)); + assert_eq!(to.y > row.center().y, below); + let mut expected: Vec = before.iter().copied().filter(|id| !moving.contains(id)).collect(); + let at = expected.iter().position(|id| *id == last).unwrap() + usize::from(below); + expected.splice(at..at, moving); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), expected, "the drop follows the indicated row boundary"); + assert_eq!(h.state().session.history.undo.len(), 1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(timeline_layer_order(&h), expected); +} + +#[test] +fn timeline_rows_drag_after_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(true, multiple); + } +} + +#[test] +fn timeline_rows_drag_before_last_visible_row() { + for multiple in [false, true] { + timeline_reorder_at_viewport_edge(false, multiple); + } +} + +/// A removed ID must not be reinterpreted as a one-based index by layer.arrange. +fn timeline_reorder_after_deletion(delete_owner: bool) { + let mut h = harness(); + for i in 0..6 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + let before = timeline_layer_order(&h); + let removed = *before.last().unwrap(); + assert!(removed > 0 && removed < before.len() as u64, "the missing ID would also resolve as a surviving layer index"); + let owner = if delete_owner { removed } else { before[0] }; + let moving = if delete_owner { vec![removed] } else { vec![owner, removed] }; + h.state_mut().session.execute("layer.select", json!({"layers": moving})).unwrap(); + h.run_steps(3); + h.state_mut().session.history.clear(); + let from = timeline_layer_row(&h, owner).center(); + start_timeline_row_drag(&mut h, from); + if !delete_owner { + // Changing the selection does not change the group captured by the held drag. + h.state_mut().session.execute("layer.select", json!({"layers": [removed]})).unwrap(); + h.run_steps(2); + } + key(&mut h, egui::Key::Delete, Default::default()); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "Delete happened during the held drag"); + let survivors: Vec = before.iter().copied().filter(|id| *id != removed).collect(); + assert_eq!(timeline_layer_order(&h), survivors, "Delete removed only the selected layer"); + let steps = h.state().session.history.undo.len(); + let row = timeline_layer_row(&h, survivors[0]); + release_timeline_row_drag(&mut h, row.center() - vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), survivors, "a stale drag cannot move any surviving layer"); + assert_eq!(h.state().session.history.undo.len(), steps, "release adds no undo step"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, survivors[0]).center(); + let row = timeline_layer_row(&h, survivors[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + let mut expected = survivors; + expected.swap(0, 1); + assert_eq!(timeline_layer_order(&h), expected, "a later independent drag still works"); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_owner_is_deleted() { + timeline_reorder_after_deletion(true); +} + +#[test] +fn timeline_rows_drag_cancels_when_a_group_member_is_deleted() { + timeline_reorder_after_deletion(false); +} + +#[test] +fn timeline_rows_drag_cancels_without_a_primary_release() { + for escape in [false, true] { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + if escape { + key(&mut h, egui::Key::Escape, Default::default()); + } else { + // egui stops its drag on another button's release too. It isn't a layer drop. + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + } + assert_eq!(timeline_layer_order(&h), before, "cancellation must not arrange layers"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a later release must not replay a cancelled drag"); + assert_eq!(h.state().session.history.undo.len(), steps); + } +} + +#[test] +fn timeline_rows_drag_cancels_when_a_modal_opens() { + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + let to = row.center() + vec2(0.0, row.height() * 0.4); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + let (modifiers, shortcut) = effectcraft_ui_egui::menus::parse_shortcut("Cmd+Alt+;").unwrap(); + key(&mut h, shortcut, modifiers); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings)); + assert!(!h.ctx.read_response(egui::Id::unique(("row", before[0]))).unwrap().enabled(), "the modal disables the Timeline row"); + assert!(h.ctx.input(|i| i.pointer.primary_down()), "the modal opened during the held drag"); + release_timeline_row_drag(&mut h, to); + assert_eq!(timeline_layer_order(&h), before, "a release behind the modal must not arrange layers"); + assert_eq!(h.state().session.history.undo.len(), steps); + key(&mut h, egui::Key::Escape, Default::default()); + assert!(h.state().dialog.is_none()); + assert_eq!(timeline_layer_order(&h), before, "closing the modal must not replay the drag"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_does_not_replay_a_release_while_hidden() { + use effectcraft_ui_egui::dock::PanelKind; + let mut h = harness(); + let before = timeline_layer_order(&h); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().close_panel(PanelKind::Timeline); + h.run_steps(2); + assert!(h.state().auto.find("timeline.layerMarquee").is_none()); + release_timeline_row_drag(&mut h, from); + h.state_mut().show_panel(PanelKind::Timeline); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let from = timeline_layer_row(&h, before[0]).center(); + let row = timeline_layer_row(&h, before[1]); + drag(&mut h, from, row.center() + vec2(0.0, row.height() * 0.4)); + assert_eq!(timeline_layer_order(&h), [before[1], before[0]]); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + +#[test] +fn timeline_rows_drag_cancels_when_the_composition_changes() { + let mut h = harness(); + let original = h.state().session.active_comp_id().unwrap(); + let before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 4})).unwrap(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Other layer", "color": "#406080", "width": 80, "height": 80})).unwrap(); + let other = h.state().session.active_comp_id().unwrap(); + let other_before = timeline_layer_order(&h); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + let from = timeline_layer_row(&h, before[0]).center(); + let steps = h.state().session.history.undo.len(); + start_timeline_row_drag(&mut h, from); + h.state_mut().session.execute("comp.open", json!({"comp": other.0})).unwrap(); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + release_timeline_row_drag(&mut h, from); + assert_eq!(timeline_layer_order(&h), other_before); + h.state_mut().session.execute("comp.open", json!({"comp": original.0})).unwrap(); + h.run_steps(3); + assert_eq!(timeline_layer_order(&h), before); + assert_eq!(h.state().session.history.undo.len(), steps); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); +} + fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 { h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0 } diff --git a/crates/ui-egui/tests/it/ui_viewer_trackpad.rs b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs new file mode 100644 index 000000000..55466371a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs @@ -0,0 +1,156 @@ +//! Trackpad gestures in the Composition viewer: a two-finger scroll pans, a pinch (and the mouse +//! wheel, also one that scrolls in pixels) zooms around the pointer (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown }); + h.run_steps(30); +} + +#[test] +fn trackpad_scroll_pans_the_composition() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(80.0, -40.0), Default::default()); + let after = rect(&h, "viewer.comp"); + let moved = after.min - comp.min; + assert!((moved.x - 80.0).abs() < 1.5 && (moved.y + 40.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((after.width() - comp.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", comp.width(), after.width()); +} + +#[test] +fn pinch_and_mouse_wheel_zoom_around_the_pointer() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "viewer.comp"); + assert!((zoomed.width() / comp.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // A mouse wheel (it scrolls in lines) zooms too, and so does Alt + a trackpad scroll. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let wheeled = rect(&h, "viewer.comp"); + assert!(wheeled.width() > zoomed.width() * 1.1, "the wheel zooms in: {} → {}", zoomed.width(), wheeled.width()); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::ALT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), egui::Modifiers::ALT); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < wheeled.width() * 0.9, "Alt + scroll zooms out: {} → {}", wheeled.width(), out.width()); +} + +/// Browsers and some high-resolution wheels report a mouse wheel in pixels, as a trackpad does: +/// it still zooms. +#[test] +fn a_pixel_unit_mouse_wheel_zooms() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > comp.width() * 1.1, "a pixel wheel zooms in: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Shift turns the wheel's scroll sideways: still a wheel, and the next notch zooms. + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::SHIFT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), egui::Modifiers::SHIFT); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + let before = rect(&h, "viewer.comp"); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), Default::default()); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < before.width() * 0.9, "the wheel still zooms: {} → {}", before.width(), out.width()); +} + +/// A straight vertical trackpad scroll pans when the touch phase says it is a trackpad, +/// and after any diagonal scroll; a mouse wheel in lines takes the zoom back. +#[test] +fn a_vertical_trackpad_scroll_pans_once_the_trackpad_is_known() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Start, + }); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 50.0), + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Move, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::End, + }); + h.run_steps(30); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 50.0).abs() < 1.5, "the scroll pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + // Without phases (Windows, Linux, the web) it stays a trackpad. + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 30.0), Default::default()); + let again = rect(&h, "viewer.comp"); + assert!((again.min.y - panned.min.y - 30.0).abs() < 1.5 && (again.width() - comp.width()).abs() < 0.5, "{again:?}"); + // A wheel that scrolls in lines is a mouse: it zooms, and so does a pixel wheel after it. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > again.width() * 1.1, "the wheel zooms: {} → {}", again.width(), zoomed.width()); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + assert!(rect(&h, "viewer.comp").width() > zoomed.width() * 1.1, "a pixel wheel zooms again"); +} + +/// Where the platform names the device (macOS), a straight vertical trackpad scroll pans at once +/// and a wheel in points zooms. +#[test] +fn the_wheel_source_decides_where_the_platform_reports_it() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + let scroll = |h: &mut Harness<'_, EffectcraftApp>, source, dy| { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source, + }); + h.run_steps(30); + }; + scroll(&mut h, egui::MouseWheelSource::Trackpad, 40.0); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 40.0).abs() < 1.5, "the trackpad pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + scroll(&mut h, egui::MouseWheelSource::Wheel, 120.0); + assert!(rect(&h, "viewer.comp").width() > panned.width() * 1.1, "the wheel zooms"); +} diff --git a/crates/ui-egui/tests/it/ui_workspace_focus.rs b/crates/ui-egui/tests/it/ui_workspace_focus.rs index 9bd5374bd..af8ecd9ad 100644 --- a/crates/ui-egui/tests/it/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/it/ui_workspace_focus.rs @@ -16,6 +16,29 @@ fn tab_id(p: PanelKind) -> Id { craft_ui::docking::DockArea::::hit_id(effectcraft_ui_egui::dock_ui::area_id(), &craft_ui::docking::Hit::Tab(p)) } +#[test] +fn workspace_commands_wait_for_migration_without_changing_named_layouts() { + let store = Arc::new(MemoryConfig::default()); + let original = include_str!("../fixtures/workspaces-v1.json"); + store.write("workspaces.json", original).unwrap(); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + assert!(!app.ui.native_outer.is_empty()); + let ctx = egui::Context::default(); + let before = (app.ui.workspace.clone(), app.ui.saved_workspaces.clone(), app.ui.dock.clone()); + for (id, params) in [ + ("window.saveWorkspace", json!({})), + ("window.saveWorkspaceAs", json!({"name": "Too soon"})), + ("window.editWorkspaces", json!({"name": "My Layout", "delete": true})), + ("window.editWorkspaces", json!({"name": "My Layout", "rename": "Too soon"})), + ] { + let error = effectcraft_ui_egui::menus::invoke(&mut app, &ctx, id, params).unwrap_err(); + assert!(error.contains("positions"), "{id}: {error}"); + assert_eq!((app.ui.workspace.clone(), app.ui.saved_workspaces.clone(), app.ui.dock.clone()), before); + assert_eq!(store.read("workspaces.json").as_deref(), Some(original)); + assert_eq!(store.read("workspaces.old.json"), None); + } +} + #[test] fn named_and_current_workspaces_survive_frontend_restart() { let store = Arc::new(MemoryConfig::default()); diff --git a/crates/vp9enc/src/bool.rs b/crates/vp9enc/src/bool.rs index 0cd91b0e1..9eda40541 100644 --- a/crates/vp9enc/src/bool.rs +++ b/crates/vp9enc/src/bool.rs @@ -7,6 +7,9 @@ pub struct BoolEncoder { range: u32, bottom: u32, bit_count: i32, + /// Start of the run of 0xFF bytes at the end of `out` (`out.len()` when it ends in another + /// byte). A carry only touches that run and the byte before it. + ff_start: usize, } impl Default for BoolEncoder { @@ -17,18 +20,37 @@ impl Default for BoolEncoder { impl BoolEncoder { pub fn new() -> Self { - BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24 } + BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24, ff_start: 0 } } + fn push_byte(&mut self, b: u8) { + if b != 255 { + self.ff_start = self.out.len() + 1; + } + self.out.push(b); + } + + /// Carry into the bytes written so far: the trailing 0xFF run becomes 0x00 and the byte + /// before it grows by one (a carry out of the first byte is dropped). Each 0xFF byte is + /// cleared once, so a long run costs O(1) per byte instead of a rescan for every carry. fn add_one(&mut self) { - let mut i = self.out.len(); - while i > 0 { - i -= 1; - if self.out[i] == 255 { - self.out[i] = 0; - } else { - self.out[i] += 1; - return; + let end = self.out.len(); + let start = self.ff_start.min(end); + if let Some(run) = self.out.get_mut(start..) { + run.fill(0); + } + self.ff_start = end; + if let Some(i) = start.checked_sub(1) + && let Some(b) = self.out.get_mut(i) + { + *b = b.wrapping_add(1); + if *b == 255 { + // The grown byte joins the 0xFF bytes before it. + let mut s = i; + while s > 0 && self.out.get(s - 1) == Some(&255) { + s -= 1; + } + self.ff_start = s; } } } @@ -51,7 +73,7 @@ impl BoolEncoder { self.bottom <<= 1; self.bit_count -= 1; if self.bit_count == 0 { - self.out.push((self.bottom >> 24) as u8); + self.push_byte((self.bottom >> 24) as u8); self.bottom &= (1 << 24) - 1; self.bit_count = 8; } @@ -80,7 +102,7 @@ impl BoolEncoder { v <<= 8; } for _ in 0..4 { - self.out.push((v >> 24) as u8); + self.push_byte((v >> 24) as u8); v <<= 8; } self.out @@ -165,4 +187,73 @@ mod tests { assert!(dec.read(1)); } } + + /// The encoder as it was before the carry run was tracked: a carry rescans the bytes. + struct Scan { + out: Vec, + range: u32, + bottom: u32, + bit_count: i32, + } + impl Scan { + fn add_one(&mut self) { + let mut i = self.out.len(); + while i > 0 { + i -= 1; + if self.out[i] == 255 { + self.out[i] = 0; + } else { + self.out[i] += 1; + return; + } + } + } + fn write(&mut self, bit: bool, prob: u8) { + let split = 1 + (((self.range - 1) * prob as u32) >> 8); + if bit { + self.bottom = self.bottom.wrapping_add(split); + self.range -= split; + } else { + self.range = split; + } + while self.range < 128 { + self.range <<= 1; + if self.bottom & (1 << 31) != 0 { + self.add_one(); + } + self.bottom <<= 1; + self.bit_count -= 1; + if self.bit_count == 0 { + self.out.push((self.bottom >> 24) as u8); + self.bottom &= (1 << 24) - 1; + self.bit_count = 8; + } + } + } + } + + #[test] + fn carry_run_tracking_matches_the_rescan() { + let mut seed = 99u32; + for case in 0..300 { + let mut fast = BoolEncoder::new(); + let mut slow = Scan { out: vec![], range: 255, bottom: 0, bit_count: 24 }; + // Skewed probabilities make long 0xFF runs and carries likely. + let extreme = case % 3; + for _ in 0..4000 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let p = match extreme { + 0 => (seed >> 8) as u8 | 1, + 1 => 1 + (seed >> 8) as u8 % 4, + _ => 255 - (seed >> 8) as u8 % 4, + }; + let bit = (seed >> 24) % 256 >= p as u32 || (seed >> 3).is_multiple_of(11); + fast.write(bit, p); + slow.write(bit, p); + assert_eq!(fast.out, slow.out, "case {case}"); + } + } + } } diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs index b9b56b693..12ad77ae7 100644 --- a/crates/vp9enc/src/lib.rs +++ b/crates/vp9enc/src/lib.rs @@ -33,12 +33,17 @@ use probs::Probs; use tables::{AC_QLOOKUP, DC_QLOOKUP}; use transform::Quant; +/// Largest width or height the 16-bit frame size fields can hold. +const MAX_DIMENSION: u32 = 1 << 16; + pub(crate) const BLOCK_8X8: u8 = 3; pub(crate) const BLOCK_16X16: u8 = 6; /// Encoder settings. #[derive(Clone, Debug, PartialEq)] pub struct EncoderConfig { + /// Frame size in samples; the stream stores size - 1 in 16 bits, so values above 65536 are + /// clamped to 65536 by [`Vp9Encoder::new`]. pub width: u32, pub height: u32, /// 1..=100; 100 is lossless. With a target bitrate: the quality of the first frame. @@ -139,7 +144,10 @@ fn quality_to_qidx(q: u8) -> u8 { } impl Vp9Encoder { - pub fn new(cfg: EncoderConfig) -> Self { + pub fn new(mut cfg: EncoderConfig) -> Self { + // The frame header stores width - 1 and height - 1 in 16 bits. + cfg.width = cfg.width.min(MAX_DIMENSION); + cfg.height = cfg.height.min(MAX_DIMENSION); let q = cfg.quality.clamp(1, 100); let lossless = q >= 100 && cfg.target_kbps.is_none(); let base_qidx = if lossless { 0 } else { quality_to_qidx(q) }; @@ -460,3 +468,16 @@ pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec, Vec, Vec = rgba.as_chunks::<4>().0.iter().take(w * h).map(|p| p[3]).collect(); (y, vec![128; cw * ch], vec![128; cw * ch]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_beyond_the_16_bit_header_fields_are_clamped() { + let enc = Vp9Encoder::new(EncoderConfig { width: 70_000, height: u32::MAX, ..Default::default() }); + assert_eq!((enc.cfg.width, enc.cfg.height), (65_536, 65_536)); + let enc = Vp9Encoder::new(EncoderConfig { width: 65_536, height: 1, ..Default::default() }); + assert_eq!((enc.cfg.width, enc.cfg.height), (65_536, 1)); + } +} diff --git a/crates/vp9enc/src/mc.rs b/crates/vp9enc/src/mc.rs index 779a1ea5f..5d897f854 100644 --- a/crates/vp9enc/src/mc.rs +++ b/crates/vp9enc/src/mc.rs @@ -56,6 +56,26 @@ pub fn predict(r: &RefPlane, x16: i32, y16: i32, w: usize, h: usize, out: &mut [ }; let fh = &SUBPEL_REGULAR[fx]; let fv = &SUBPEL_REGULAR[fy]; + if inside && w <= 16 && h <= 16 { + // Rows straight from the reference (no edge clamping needed). + let (xs, ys) = ((x0 - 3) as usize, (y0 - 3) as usize); + let row = |i: usize| &r.data[(ys + i) * r.stride + xs..]; + if fx == 0 && fy == 0 { + for i in 0..h { + out[i * w..i * w + w].copy_from_slice(&row(i + 3)[3..3 + w]); + } + return; + } + let mut tmp = [0u8; 23 * 16]; + for i in 0..h + 7 { + let s = row(i); + filt8(std::array::from_fn(|t| &s[t..]), fh, &mut tmp[i * w..i * w + w]); + } + for i in 0..h { + filt8(std::array::from_fn(|t| &tmp[(i + t) * w..]), fv, &mut out[i * w..i * w + w]); + } + return; + } if fx == 0 && fy == 0 { for i in 0..h { for j in 0..w { @@ -108,24 +128,23 @@ impl PhasePlanes { let rh = ph + 2 * M; let off = (border + M) as i32; let mut raw = vec![0u8; rs * rh]; - for y in 0..rh { + let o = off as usize; + for (y, row) in raw.chunks_exact_mut(rs).enumerate() { let sy = (y as i32 - off).clamp(0, r.h as i32 - 1) as usize; let src = &r.data[sy * r.stride..sy * r.stride + r.w]; - for x in 0..rs { - raw[y * rs + x] = src[(x as i32 - off).clamp(0, r.w as i32 - 1) as usize]; - } + // Edge replication: src[clamp(x - off, 0, w - 1)]. + let (left, rest) = row.split_at_mut(o.min(rs)); + left.fill(src[0]); + let n = r.w.min(rest.len()); + rest[..n].copy_from_slice(&src[..n]); + rest[n..].fill(src[r.w - 1]); } // Horizontal phases over every raw row (inner columns). let hpass = |fx: usize| -> Vec { let f = &SUBPEL_REGULAR[fx]; let mut out = vec![0u8; pw * rh]; - for y in 0..rh { - let row = &raw[y * rs..y * rs + rs]; - for x in 0..pw { - let s = &row[x + M - 3..x + M + 5]; - let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t] as i32).sum::(); - out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; - } + for (row, o) in raw.chunks_exact(rs).zip(out.chunks_exact_mut(pw)) { + filt8(std::array::from_fn(|t| &row[M - 3 + t..]), f, o); } out }; @@ -140,14 +159,8 @@ impl PhasePlanes { } let f = &SUBPEL_REGULAR[fy * 4]; let mut out = vec![0u8; pw * ph]; - for y in 0..ph { - for x in 0..pw { - let mut acc = 64; - for t in 0..8 { - acc += f[t] * hp[(y + M + t - 3) * pw + x] as i32; - } - out[y * pw + x] = (acc >> 7).clamp(0, 255) as u8; - } + for (y, o) in out.chunks_exact_mut(pw).enumerate() { + filt8(std::array::from_fn(|t| &hp[(y + M + t - 3) * pw..]), f, o); } planes.push(out); } @@ -167,6 +180,51 @@ impl PhasePlanes { } } +/// `out[x] = clip(Round2(sum_t f[t] * s[t][x], 7))` for 8-bit samples. Lanes are 16-bit sums +/// kept modulo 2^16: with the regular filter every sum plus the rounding term lies in +/// [-13706, 46474], so wrapped non-negative sums stay below 0xC000 and negative ones (clipped +/// to 0) land above it (the decoder's `narrow8`). +#[inline] +fn filt8(s: [&[u8]; 8], f: &[i32; 8], out: &mut [u8]) { + let n = out.len(); + let mut x = 0; + while x + 32 <= n { + filt_chunk::<32>(&s, f, out, x); + x += 32; + } + for w in [16, 8, 4] { + if x + w <= n { + match w { + 16 => filt_chunk::<16>(&s, f, out, x), + 8 => filt_chunk::<8>(&s, f, out, x), + _ => filt_chunk::<4>(&s, f, out, x), + } + x += w; + } + } + for j in x..n { + let acc: i32 = 64 + (0..8).map(|t| f[t] * s[t][j] as i32).sum::(); + out[j] = (acc >> 7).clamp(0, 255) as u8; + } +} + +#[inline(always)] +fn filt_chunk(s: &[&[u8]; 8], f: &[i32; 8], out: &mut [u8], x: usize) { + let mut acc = [64u16; W]; + for t in 0..8 { + let c = f[t] as u16; + let Some(p) = s[t].get(x..).and_then(|p| p.first_chunk::()) else { return }; + for j in 0..W { + acc[j] = acc[j].wrapping_add(c.wrapping_mul(p[j] as u16)); + } + } + if let Some(o) = out.get_mut(x..).and_then(|o| o.first_chunk_mut::()) { + for j in 0..W { + o[j] = if acc[j] < 0xC000 { (acc[j] >> 7).min(255) as u8 } else { 0 }; + } + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/vp9enc/src/probs.rs b/crates/vp9enc/src/probs.rs index 57af2e29b..c089cb96f 100644 --- a/crates/vp9enc/src/probs.rs +++ b/crates/vp9enc/src/probs.rs @@ -216,7 +216,32 @@ fn inv_remap_prob(delta: usize, prob: u8) -> u8 { /// The `decode_term_subexp` value that moves `old` to `new`. fn delta_for(old: u8, new: u8) -> Option { - (0..255).find(|&d| inv_remap_prob(d, old) == new) + match delta_table().get(old as usize * 256 + new as usize) { + Some(&d) if d != NO_DELTA => Some(d as usize), + _ => None, + } +} + +/// Marks "no delta reaches this probability" in [`delta_table`]. +const NO_DELTA: u8 = 255; + +/// `delta_for` for every (old, new) pair, indexed `old * 256 + new`: the smallest delta that +/// moves `old` to `new`. Filled from the largest delta down so the smallest one wins. +fn delta_table() -> &'static [u8] { + use std::sync::OnceLock; + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| { + let mut t = vec![NO_DELTA; 256 * 256]; + for old in 0..=255u8 { + for d in (0..255usize).rev() { + let new = inv_remap_prob(d, old); + if let Some(e) = t.get_mut(old as usize * 256 + new as usize) { + *e = d as u8; + } + } + } + t + }) } /// Bits of `decode_term_subexp` for `d`. @@ -419,6 +444,16 @@ mod tests { } } + #[test] + fn delta_table_matches_the_linear_search() { + for old in 0..=255u8 { + for new in 0..=255u8 { + let slow = (0..255).find(|&d| inv_remap_prob(d, old) == new); + assert_eq!(delta_for(old, new), slow, "{old} -> {new}"); + } + } + } + #[test] fn inv_map_table_shape() { let t = inv_map_table(); diff --git a/crates/vp9enc/src/tile.rs b/crates/vp9enc/src/tile.rs index 1f0c37d80..082d0bd83 100644 --- a/crates/vp9enc/src/tile.rs +++ b/crates/vp9enc/src/tile.rs @@ -222,6 +222,21 @@ fn probs_defaults() -> &'static probs::Probs { D.get_or_init(probs::Probs::defaults) } +/// SAD of two N×N blocks: per-column 16-bit sums (at most 16 · 255), summed once. +#[inline(always)] +fn sad_n(a: &[u8], sa: usize, b: &[u8], sb: usize) -> u32 { + let mut acc = [0u16; N]; + for i in 0..N { + let (Some(ra), Some(rb)) = (a.get(i * sa..).and_then(|r| r.first_chunk::()), b.get(i * sb..).and_then(|r| r.first_chunk::())) else { + return u32::MAX / 4; + }; + for j in 0..N { + acc[j] += ra[j].abs_diff(rb[j]) as u16; + } + } + acc.iter().map(|&v| v as u32).sum() +} + // ---------------------------------------------------------------- the tile /// Per-frame coding parameters shared by the tiles. @@ -601,12 +616,18 @@ impl<'a> Tile<'a> { let (x16, y16) = (((c * 8) << 4) as i32 + 2 * mv.col, ((r * 8) << 4) as i32 + 2 * mv.row); let mut s = 0u32; if let Some((pl, o, st)) = self.f.phases.as_ref().and_then(|ph| ph.block(x16, y16, n)) { - for i in 0..n { - let a = &p.src[(r * 8 + i) * p.w + c * 8..][..n]; - let b = &pl[o + i * st..o + i * st + n]; - s += a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::(); - } - return s; + let a = &p.src[(r * 8) * p.w + c * 8..]; + let b = &pl[o..]; + return match n { + 8 => sad_n::<8>(a, p.w, b, st), + 16 => sad_n::<16>(a, p.w, b, st), + _ => { + for i in 0..n { + s += a[i * p.w..][..n].iter().zip(&b[i * st..][..n]).map(|(a, b)| a.abs_diff(*b) as u32).sum::(); + } + s + } + }; } // Inter search only runs on frames with a reference. let Some(refs) = self.f.refs.as_ref() else { return u32::MAX / 4 }; @@ -630,7 +651,7 @@ impl<'a> Tile<'a> { let d = &probs_defaults().0; let mb: [u32; 4] = std::array::from_fn(|m| tree_cost(&INTER_MODE_TREE, &d[probs::INTER_MODE + mctx * 3..], m as u8)); let mode_bits = |m: u8| mb[(m - NEARESTMV) as usize]; - let cost = |mv: Mv| -> f64 { + let cost_raw = |mv: Mv| -> f64 { let bits = if mv == nearest { mode_bits(NEARESTMV) } else if mv == Mv::ZERO { @@ -642,6 +663,21 @@ impl<'a> Tile<'a> { }; self.sad(r, c, n, mv) as f64 + lambda * bits as f64 }; + // The cost is a pure function of the vector: points the search revisits (the centre it + // came from, candidates on the diamond) are looked up instead of predicted again. + let mut seen = [(Mv::ZERO, 0.0f64); 64]; + let mut nseen = 0usize; + let mut cost = |mv: Mv| -> f64 { + if let Some(&(_, j)) = seen[..nseen].iter().find(|e| e.0 == mv) { + return j; + } + let j = cost_raw(mv); + if nseen < seen.len() { + seen[nseen] = (mv, j); + nseen += 1; + } + j + }; let mut best = (Mv::ZERO, cost(Mv::ZERO)); let prev = self.f.prev_mvs.map(|p| p[r * self.f.mi_cols + c]); for cand in [Some(nearest), Some(near), prev].into_iter().flatten() { diff --git a/crates/vp9enc/src/transform.rs b/crates/vp9enc/src/transform.rs index e6a685d5a..20996d60b 100644 --- a/crates/vp9enc/src/transform.rs +++ b/crates/vp9enc/src/transform.rs @@ -144,6 +144,16 @@ pub fn forward_matrices() -> &'static [[[f64; 16]; 16]; 4] { }) } +/// [`forward_matrices`] transposed: `[type][q][k]`. +fn forward_matrices_t() -> &'static [[[f64; 16]; 16]; 4] { + use std::sync::OnceLock; + static M: OnceLock<[[[f64; 16]; 16]; 4]> = OnceLock::new(); + M.get_or_init(|| { + let f = forward_matrices(); + std::array::from_fn(|t| std::array::from_fn(|q| std::array::from_fn(|k| f[t][k][q]))) + }) +} + fn invert16(m: [[f64; 16]; 16]) -> [[f64; 16]; 16] { let mut a = m; let mut inv = [[0.0; 16]; 16]; @@ -191,12 +201,23 @@ impl Quant { if self.lossless { return forward_wht(res); } - let f = &forward_matrices()[tx_type as usize]; + let ft = &forward_matrices_t()[tx_type as usize]; + // Every output accumulates its 16 products in the same order (q ascending) as a + // per-output sum, but the 16 outputs advance together: 16 independent chains that + // vectorize instead of one serial chain per output. + let mut acc = [-0.0f64; 16]; + for (q, col) in ft.iter().enumerate() { + let r = res[q] as f64; + for k in 0..16 { + acc[k] += col[k] * r; + } + } let mut lv = [0i32; 16]; for k in 0..16 { - let coef: f64 = (0..16).map(|q| f[k][q] * res[q] as f64).sum(); + let coef = acc[k]; let (qs, rnd) = if k == 0 { (self.dcq, self.round_dc) } else { (self.acq, self.round_ac) }; - let l = (coef.abs() / qs as f64 + rnd).floor() as i32; + // Non-negative: truncation is floor. + let l = (coef.abs() / qs as f64 + rnd) as i32; lv[k] = if coef < 0.0 { -l } else { l }; } lv diff --git a/docs/agents.md b/docs/agents.md index d2b691b18..7f3fd9ad3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -101,7 +101,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | Tool | What it does | |---|---| -| `list_commands {filter?, enabled_only?, schemas?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now. | +| `list_commands {filter?, enabled_only?, schemas?, compact?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now; `compact` lists only ids and labels (and overrides `schemas`). | | `describe_command {command \| id}` | One command in full: label, menu, shortcut, params doc, JSON `schema`, enabled / `why`. | | `execute_command {command \| id, params?}` | Run any command (undoable). Unknown parameter keys are rejected with the accepted list. | | `get_state` | Editor state (active comp, time, selections, tool) plus `app`: version, command and effect counts, export formats, parity summary. | @@ -111,7 +111,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | -| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | +| `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. | | `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | @@ -212,8 +212,25 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built or call `save_project {"path": …}` after each milestone. - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid - layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`) - ([#261](https://github.com/storytold/effectcraft/issues/261)). + layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), + or a default studio environment — bright above and toward the initial camera, dark below and + behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either + way the light brightens the scene. A selected Source or Environment Layer that fails to + render — a deleted layer, missing footage — lights nothing instead: the light never silently + falls back to the studio default. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights: Cast Shadows shades the scene from the image's dominant bright region + (the default environment's key, or a source image's brightest area; an image without a + sufficiently strong dominant direction has none — a uniform one, or opposing bright regions + that cancel — so it casts no shadow) — the region weighted by the light's colour, so a + coloured light shadows from the regions it actually lights (opposing red and blue regions + cancel to a white light, but a red light's shadow comes from the red one) — occluding only + that key's share of the light, so the + shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra + of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights + Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a + Classic 3D comp to Advanced 3D + ([#261](https://github.com/storytold/effectcraft/issues/261), + [#2436](https://github.com/storytold/effectcraft/issues/2436)). ### Cookbook (from end-to-end QA) @@ -272,10 +289,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new @@ -446,7 +463,12 @@ and Origin), `camera.createFromSolve {kind: text|solid|null|shadowCatcher, point {center, normal, size?}, multiple?}` (Create Text / Solid / Null / Shadow Catcher, Camera and Light, Create Multiple …), `camera.deletePoints {points?, wait?}` (re-solves; with Auto-delete Points Across Time the same feature's other tracks go too) and `camera.create` (the Create Camera button). -Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. +Each edit is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. + +`camera.exportBlender {path: ".py", layer?, effect?, sensorWidth?: 36, unitScale?: 0.01, +startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script: +baked camera/lens/focus, synced video background, points and ground. See +[Blender camera export](blender-camera-export.md) for framing, assumptions and limitations. Changing the layer's frames clears the analysis; changing `effects/#1/shotType`, `effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks. @@ -540,7 +562,7 @@ indices): character attributes 3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes. 4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly` - work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). + work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name @@ -583,7 +605,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). @@ -614,12 +638,21 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a - drop on that value field does. + drop on that value field does. `byIndex: true` (an Alt pick whip: Alt held as it drops) refers + to an effect's properties by their index, `effect("Color Control")(1)`: it still reads a + renamed property, but another one if properties are reordered. * Every property row has a property pick whip in the Parent & Link column (`timeline.prop..propertyPickWhip`; the expression row's is `timeline.prop..pickWhip`). Dragged onto another property's name or value, in the Timeline or Effect Controls (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the property's expression, or is added as one, and the expression is enabled. +* Parent and Track Matte for several layers: on a selected layer's row, the Timeline's Parent + menu and pick whip (`timeline.layer..parent`, `timeline.layer..pickWhip`) and its Track + Matte menu and pick whip (`timeline.layer..trackMatte`, + `timeline.layer..trackMattePickWhip`) set every selected layer's (on an unselected row, + that layer's), in one undo step. Directly: `layer.setParent {layers, parent}` and + `layer.setTrackMatte {layers, matte, kind}`, where `kind: "preserve"` keeps each layer's + own matte mode. * A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset (`prop.reset`: no expression, and the property's default value, an effect parameter's or a Transform property's; an animated property keeps its keyframes and gets the default as a @@ -680,6 +713,19 @@ effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress # progress lines on stderr for a program that reads them ``` +A long `run` doesn't fit on a command line (Windows caps it at 32 767 characters, Linux one +argument at 128 KiB). `run --args-file F` reads the same ` ` arguments one per line; +`run --commands FILE` reads steps as a JSON array of `{"command": ID, "params": {…}}` objects, or one +such object per line (JSON Lines), and `--commands -` reads them from standard input. `--commands` +may be repeated; its steps run after the positional pairs and `--args-file`. A malformed step is a +usage error naming its index (from 0, as in the results) before anything runs, and the error of a step +that fails ends with `(step N)`: + +```sh +effectcraft-cli run --commands steps.json --empty --save-as main.ecproj +generate-steps | effectcraft-cli run main.ecproj --commands - --save --json +``` + Scripts written against After Effects' documented scripting API run with `script`: ```sh diff --git a/docs/architecture.md b/docs/architecture.md index d3dd4db69..f52c93e1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown | | L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, Photoshop and SVG stills, frame cache | | L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model | -| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF | +| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR/Targa sequences, GIF, WAV/AIFF | | L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer | | L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) | | L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express | @@ -158,9 +158,22 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip -chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps -(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep +Environment Layer, or a default studio environment — bright above and toward the initial +camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity +when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed +mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps +(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: +one orthographic from the image's dominant bright region — none only when the image lacks a +sufficiently strong dominant direction (a uniform one, or opposing bright regions that +cancel) — the region weighted by the light's colour, so a coloured light shadows from the +regions it actually lights (opposing red and blue regions cancel to a white light, but a red +light's shadow comes from the red one) — occluding only the image's directional key fraction of +the light, so the shadow stays partial like After Effects' area-light shadows, with a soft +default penumbra of at least eight pixels or four map texels). A Source or Environment Layer +that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback +to the default studio environment. An Environment light only lights Advanced 3D comps, +so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, +as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the @@ -345,7 +358,10 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. A frame whose render panics is +the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +validation errors (an invalid texture, often left by a failed allocation): that frame renders on +the CPU and the kept textures are freed, but the compositor stays on and, with no allocation +failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is rendered again on the CPU or released, never left in progress. Native readbacks and timing waits share a finite deadline; failure retires pending readbacks exactly once, and late results are discarded. The desktop executable and browser page own their shared device's error/loss handlers and forward @@ -368,6 +384,13 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every +step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; +the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +checkpoint's births). The rate's keyframes are read at the step's exact layer time +(`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional +stretch), a rate without keyframes or an expression isn't sampled, and a layer of one of them with +expressions isn't layer cached (its key can't see what an expression returned at earlier times). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an adjustment-layer comp. @@ -391,7 +414,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the +collapsed context and are applied while the nested layers are evaluated, so they do not force a +flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened +render. Text layers are always rasterised at their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, @@ -458,8 +484,10 @@ effects crate, hence the `effects → track` edge. `essential` field lists its exposed controls (properties, Media Replacement, groups, comments). Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`, children `eg`), kept in step by `Session::edit`; editing a child overrides it for -that instance. The renderer draws an instance with overrides from a copy of the project with the -overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate` +that instance. A collapsed instance carries its property, Font and Media Replacement overrides +into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`); +other instances are drawn from a copy of the project with the overridden source properties set +(`essential::with_overrides`). Templates are `.ectemplate` ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and `poster.png`; importing offsets every id past the project's (`essential::offset_ids`). **Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected diff --git a/docs/blender-camera-export.md b/docs/blender-camera-export.md new file mode 100644 index 000000000..d102eef4f --- /dev/null +++ b/docs/blender-camera-export.md @@ -0,0 +1,101 @@ +# Exporting a tracked camera to Blender + +After the **3D Camera Tracker** finishes solving, click **Export Scene to Blender...** in its +Effect Controls and save the `.py` file. In Blender 4.0 or newer, open it in the Scripting +workspace's Text Editor and choose **Run Script**. The script creates and activates a new scene; +it preserves existing scenes and objects. Save the resulting `.blend` normally. + +The script uses Blender's public Python API, with no add-on or Python dependencies beyond +Blender itself. For unattended use: `blender --background --python "Tracked Scene.py"`. +That imports the scene in memory; it does not automatically save a `.blend`. + +## Transferred data + +- Camera position and quaternion rotation baked on every analyzed frame, with consistent + quaternion signs and linear interpolation; zoom/lens animation, including Variable Zoom. +- Tracking input/video resolution, horizontal sensor fit, exact rational composition FPS, + frame range and composition markers within the analyzed interval. The first analyzed frame + becomes Blender frame 1 by default; the original composition timestamps remain in metadata. +- The original video or still as the camera's background image, at full opacity, refreshed on + frame changes. Source-frame offsets are baked too, accounting for trim, stretch, source FPS + and Time Remap. This is a viewport background, not a render/compositor background. +- Focus distance, DOF switch, f-stop, iris blade count, rotation and aspect sampled from the + tracker's linked **3D Tracker Camera**, including keyframes and expressions. Create that + camera and adjust its Camera Options before exporting to transfer custom focus settings. +- Solved 3D points as a non-rendering vertex mesh, with point attributes for track ID, + reprojection error and first/last visible frame. A chosen ground plane becomes Blender's XY + plane, with its origin preserved and a non-rendering wire plane as a placement guide. +- Shutter duration (not automatically enabled), plus the original shutter phase in metadata. +- A Blender Text datablock named `EffectCraft Tracking Metadata.json` containing the complete + solve, optional original 2D tracks, per-frame timings, source interpretation, diagnostics and + export warnings. Radial distortion coefficients are also camera custom properties. + +Coordinates are converted from EffectCraft's right/down/forward world to Blender's +right/forward/up world, including the camera's local up and negative-Z viewing direction. +Export uses the tracking input's framing, not the footage layer's final 2D placement in the +composition. Camera pose and lens always come from the solve; subsequent manual pose/lens edits +on the linked camera are not substituted for the tracked motion. + +## What cannot be inferred + +Monocular tracking cannot recover absolute scene scale, physical sensor size, optical focus, +f-stop or exposure from feature tracks. The default sensor width is an assumption of **36 mm**; +the focal length in millimeters is derived from it while preserving the solved field of view. +The default scale is **0.01 Blender units per composition-world pixel**, not a measurement. +Without a linked camera, DOF is disabled and focus distance uses the default solved scene depth. +Blender's physical DOF approximates EffectCraft's blur model: Blur Level and Iris Roundness are +retained in metadata, but have no direct Blender equivalent. + +Brown-model radial `k1`/`k2` coefficients use radius normalized to half the image diagonal. +They are preserved, **not** assigned to Blender's different lens-distortion model. For a precise +match when distortion is nonzero, use an undistorted plate; the script prints a warning. +The tracker assumes square pixels, so Blender renders square pixels; any non-square source +interpretation is retained in metadata and flagged. + +The `.py` references the video; it does not contain a copy of the video's bytes. When moving it, +keep the video next to the script (same filename), or relink the background image in Blender. +Missing/unsupported media produces a warning while retaining the imported camera and geometry. +Masks and effects applied before the tracker are not baked into the original media. Export +requires a direct video/still footage layer at the analyzed size; render precomps, sequences or +size-changing preprocessing to a video and track that video first. + +## Agent / control-channel command + +```json +{ + "command": "camera.exportBlender", + "params": { + "layer": "#1", + "path": "/output/Tracked Scene.py", + "sensorWidth": 36, + "unitScale": 0.01, + "startFrame": 1, + "includePoints": true + } +} +``` + +`comp`, `layer` and `effect` select the tracker as with `camera.analyze`. Without explicit +selection, the selected tracker (or first tracker in the composition) is used. `sensorWidth`, +`unitScale`, `startFrame` and `includePoints` are optional. The reply contains file size, +exported frame/point counts and warnings. Writing uses the host's atomic file service. +Calls issued by scripts require **Allow Scripts to Write Files and Access Network** in +Settings ▸ Scripting & Expressions. Export from the UI or control channel remains available +with that preference off. + +Export is limited to 100,000 analyzed frames, 100,000 points and 64 MiB of serialized scene data; +invalid, stale or incomplete solves return an actionable error without modifying the document. + +## Verification + +Engine tests check argument validation, escaped/Unicode paths, fractional FPS, trim/stretch, +focus/f-stop, zoom, ground placement, malformed solves and write failures. To additionally run +the generated artifact against an installed Blender's public API: + +```sh +EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture +``` + +Optionally set `EFFECTCRAFT_BLENDER_PLATE` to a local synthetic 640×360 video to exercise media +loading and source-frame offsets. The oracle checks every synthetic point's projection through +the imported camera on every frame, as well as animation, DOF, timing and scene preservation. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c7548835..d9049306c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,7 +103,7 @@ Points are logical window coordinates. A target is `{id}` (the element's centre; | `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | | `ui.move` | `{id \| x,y}` | | `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | -| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.scroll` | `{id \| x,y, dx?, dy?, unit?: point\|line, source?: wheel\|trackpad, pinch?, modifiers?}`: `line` is a mouse wheel (zooms the viewers); `point` (default) is a pixel-precise scroll; `source: trackpad` pans the viewers (as on macOS); with no `source` the viewers take a scroll in points as a wheel until one moves on both axes (a trackpad: from then on it pans); `pinch` is a trackpad pinch's zoom factor | | `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | | `ui.type` | `{text}`: text input into the focused field | @@ -210,7 +210,9 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}`, `resizeSplit {path, size}`; `rect` is `[x, y, width, height]`). It returns `{"docking": workspace}`. `ui.dock.reset` returns the current workspace to its saved or - built-in layout and returns the same. + built-in layout and returns the same. Raw `Open` and `OpenAt` actions are refused in both + `ui.dock {action}` and `window.panel.layout {action}`, as in `window.dockAction`; use the + panel open commands instead. - `window.dockPanel {panel, anchor?, zone?, before?}` docks as `window.panel.dock`: `zone` is `center`, `tab`, `left`, `right`, `top`, `bottom`, `above` or `below`; without an anchor a side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}` diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 83e9a15bc..bd81a09df 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | | **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); Radial Blur, Glow, Venetian Blinds, Radial Wipe, Fractal / Turbulent Noise and Transform are checked the same way (rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | -| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | +| Animation presets | **76** original presets (8 text-animator, 23 Transitions, 23 Backgrounds, 22 Behaviors) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`, `crates/engine/presets/behaviors.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | **Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a @@ -56,7 +56,7 @@ implemented under a generic name ("Key Light"). **≈ 70–130 h**: a fidelity corpus case per effect at default and one non-default setting, compared with After Effects renders, and the fixes they turn up (40–80 h, most of it parallel, but every reference render needs After Effects on the owner's Mac); an original preset library covering -After Effects' preset categories (Behaviors, Shapes, Synthetics, Text, Image effects remain; Backgrounds and +After Effects' preset categories (Shapes, Synthetics, Text, Image effects remain; Backgrounds, Behaviors and Transitions exist) 15–35 h; the open effect bugs 10–20 h. Moving Fractal and the analysis effects to the GPU is performance, not parity. diff --git a/docs/effects.md b/docs/effects.md index b8214e438..c6fb40922 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 308 effects with 3033 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. -- **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 307 implemented in full, 0 partial (what is missing is listed). +- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 308 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -16,6 +16,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | +| 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..0398118c5 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,55 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + +## Rectangle path start point + +Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim +Paths: a plain rectangle starts at its top-right corner and runs clockwise (0–25 % covers the right +edge and 50 px of the bottom); with Roundness 40 it starts where the top-right arc meets the right +edge. Start 70 / End 30 draws 30–70 %. EffectCraft started at the top-left corner (and the rounded +rectangle elsewhere), so trimmed rectangles drew the wrong segment (86 of 5184 grid points off, +now 5 at stroke edges). `effectcraft_path::rect` now draws AE's order, which also gives Convert to +Bezier AE's vertex order. Regression test: `rectangles_start_where_after_effects_starts_them`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..45e373cda 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -48,8 +48,8 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | -| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | none | none | Media Browser lists it | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | @@ -96,19 +96,19 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | | Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | -## Render queue outputs (`OutputFormat::ALL`, 13) +## Render queue outputs (`OutputFormat::ALL`, 14) H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM; -#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF. +#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR / Targa sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed -single-file video (Animation / PNG codecs), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware +single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxHR, hardware encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file (#325). ## Estimate **≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / -HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h, +HDR / MXF / AVI / DNx import and DPX output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h, relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). ## Revision history diff --git a/docs/footage.md b/docs/footage.md index bd99cbeb2..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR @@ -129,9 +134,10 @@ including files overwritten at the same path and byte size. and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a layer is one layer at a time, as in After Effects. Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. -- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered - file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, - "sequence": false}` for one frame). +- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. A still stays a + still, and a sequence item keeps bringing the new numbered file's run; pass + `sequence: true` to bring a numbered still's whole run (`file.replaceFootage {"path": …, + "sequence": true}`), or `sequence: false` for one frame. - **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the Project panel, puts the item in a new composition of its size, pixel aspect and frame rate, in the same folder, and switches every layer in every composition diff --git a/docs/gaps.md b/docs/gaps.md index 3f59b1f99..6851fddc1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -63,17 +63,17 @@ are grouped by area; the issue tracker has the rest. | 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | -| 7 | **Animation presets: 54 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 54 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops) and read only our `.ecpreset`. Missing: Behaviors, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | @@ -133,6 +133,12 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#947](https://github.com/storytold/effectcraft/issues/947): the + preview startup tests in `ui_audio_scrub`, checked headlessly on Windows with a fake output + device, start an audible preview on an uncached comp. The playhead holds the first frame until + the frames ahead are cached, the sound starts at that frame (from 0 s and from 1 s, also for a + 30 s work area), and a tone lasting only the first 100 ms reaches the output. Video off still + starts at once, no device plays the picture, and Stop while preparing leaves nothing playing. - Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They @@ -163,6 +169,11 @@ The most important missing piece: it turns every other estimate here into a meas a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, and the words, Solid swatches and Alt-click are unchanged. +- Regression evidence for [#1306](https://github.com/storytold/effectcraft/issues/1306) (from + #1072): the `ui_expression_values` tests, checked headlessly on Windows, read the colour the + values are painted in. Expression-driven vectors, angles and scalars are red in the Timeline, + Effect Controls and Properties panel, in dark and light themes, hovered too; disabled, removed + and empty expressions show the ordinary colour. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template @@ -299,8 +310,11 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard - shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Each File ▸ Scripts entry can be given a key in + Edit ▸ Keyboard Shortcuts. Missing: a global scope that scripts share for the session (a Startup + script's variables and functions are not available to later scripts) and `.jsxbin`; + `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0a0a85a97..c25da4928 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -23,7 +23,7 @@ monitors are missing, and two GPU start-up failures are open. | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | | Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | -| Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | +| Touch / trackpad gestures | pinch zoom in viewers | Composition and Layer viewers: two-finger scroll pans, pinch zooms around the pointer (Alt + scroll and the mouse wheel zoom too); Timeline pinch zooms time | two-finger scroll pans once it moves diagonally (a pixel-precise wheel keeps zooming); pinch arrives as Ctrl + scroll and zooms | same | same | No rotate or swipe gestures; tested in `ui_viewer_trackpad.rs` (UI logic, not on hardware) | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late after a start offset (#504 open; #554 closed) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | | Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d642af43c..59b3f792c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** minor (Polish (`pl`): menus 630, panels 695; recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -17,28 +17,31 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## How EffectCraft translates - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by - `(command, English label)` (624 catalog rows each at 470ea7a3, counted). A test - (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a row whose entry left the menu tree - or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries - and category submenus take their labels from the effect catalog below. + `(command, English label)`. A test (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a + row whose entry left the menu tree or on a repeated row, so every catalog covers all fixed + entries (630 catalog rows each at this tree, counted; 629 in Georgian). The generated Effect menu is not part of + that fixed tree: its entries and category submenus take their labels from the effect catalog + below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and - Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. Georgian has every row the Chinese catalogs have at this revision (695, as - both Chinese catalogs now have), plus 191 for panels converted to the catalog. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified + Chinese (998), Traditional Chinese (951, after #2244 pruned the table), Ukrainian (524), + Brazilian Portuguese (695), Czech (518) and Spanish (513); counted at this tree. Georgian has + the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since + (885 in all). - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids) and Georgian + (307 rows, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no - panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those - languages draw English until a translator fills them (the Japanese set for #821 is what that - slot is for). Each language decides for itself which names stay English — Adobe's Japanese - interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates - them — so a row repeating the English name is a choice, not a missing row. + panel changes: Traditional Chinese has its table in place and empty, and Japanese fills 297 of + the 307 ids, so the rest draw English until a translator completes them. Each language decides + for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and + `OCIO` sets in English while its Chinese one translates them — so a row repeating the English + name is a choice, not a missing row. - **Missing strings:** a string with no row in the interface language shows in English, and no test, build or release fails on a missing translation. The `missing_translations_report` test (`crates/ui-egui/src/i18n.rs`) prints, per language, the fixed menu entries, panel strings, effect @@ -60,48 +63,52 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27, recounted on 22 October 2026 at `ad8b6acb`, +and recounted again at this tree after #1774 by counting catalog rows (`const` tables in +`crates/ui-egui/src/i18n.rs`, `i18n/ui.rs` and `i18n/effects.rs`: 630 menu rows, 998 +panel/dialog sources in Simplified Chinese, 951 in Traditional Chinese and 996 in Japanese). The +denominator, **≈ 4,200 user-visible strings**, is a rounded estimate: the 630 fixed menu entries, +the 998 panel/dialog sources, 307 effect names, ≈ 1,650 distinct effect parameter names and +≈ 620 other unwrapped interface strings (Home screen literals, engine-built tooltips, error and +status messages). It counts distinct strings, not catalog rows; engine messages are only partly +counted, so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | -| Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,935 (≈ 46%): menus 630 rows, panels 998, effect names 307 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | -| Portuguese (Brazil) | `pt-br` | 1,024 (≈ 26%): menus 624 rows, panels 400 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | +| Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,034 (≈ 27%): menus 624 rows, panels 410 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 307 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (4): +Other languages EffectCraft ships (5): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | -| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Traditional Chinese | `zh-hant` | 1,581 (≈ 38%): menus 630 rows, panels 951 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | +| Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | +| Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 18%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 29%, Japanese 27%, Brazilian Portuguese 26%, Spanish, French, -German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 24%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 38%, Japanese 46%, Brazilian Portuguese 34%, +Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 241 / 10, was ≈ 21% on +22 October; Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that +average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -111,8 +118,12 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-30 | minor | Recounted on the merged tree after #1774: Japanese 1,923 (46%, 297 effect names), Simplified Chinese 1,935 (46%, 307 effect names), Traditional Chinese 1,581 (38%, 951 panel rows after #2244's prune); overall ≈ 24% | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Simplified Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; the 30 October recount above carries the share | +| 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | -| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | -| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-29 | minor | Polish (`pl`): menus 630, panels 695 | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | +| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/mcp.md b/docs/mcp.md index 1104927ac..a9f27b959 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ The server provides the same core conventions as FilmCraft #28: | Tool | Arguments | Result | | --- | --- | --- | -| `command_list` | `filter?`, `enabled_only?`, `schemas?` | Command catalog | +| `command_list` | `filter?`, `enabled_only?`, `schemas?`, `compact?` | Command catalog | | `command_run` | `id`, `params?` | Engine command result | | `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | `completed`, `failed`, per-step `results` | | `doc_inspect` | none | Project overview and active composition | diff --git a/docs/plugins.md b/docs/plugins.md index c070e3d98..3b9182a60 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -12,13 +12,13 @@ Guide): | After Effects | EffectCraft | | |---|---|---| | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | -| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | +| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | -| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | -| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | +| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | @@ -39,8 +39,17 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. +* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts + are named `Folder/File.jsx` in `file.scripts.list` and `file.runScript`. One level of folders is + listed; deeper folders are not. `file.uninstallScript` removes only scripts at the top of the + Scripts folders: delete a script in a subfolder yourself. A subfolder may be a link to another + folder (an extension's own), and uninstall never deletes a file through it. ## ScriptUI panels @@ -52,6 +61,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps run at start-up; the list is `scriptui_panels.json` in the settings folder). +To group panels, for example the several panels of one extension, make a subfolder of +`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is +listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it +`My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install +ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it +yourself, and delete them from it yourself too. + Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating palette itself. A minimal panel: diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..6d692a936 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions @@ -119,9 +120,11 @@ Commands (CLI, MCP, control channel): - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) - `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as - in After Effects) or `all` (image sequences too). Every two seconds, - and as soon as the window comes back to the front, the desktop app compares each footage file's - size and modification time with what they were, and reloads what another app saved: a + in After Effects) or `all` (image sequences too). Every two seconds while its window is in + front (every ten seconds while another app has the front), and as soon as the window comes back + to the front, the desktop app compares each footage file's size and modification time with what + they were, on a worker thread that only wakes the interface when something changed, and reloads + what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. @@ -189,7 +192,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 5997a8bd1..96e298ece 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,13 +20,13 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | -| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | -| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | +| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | -| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | @@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff7cc3ef9..6c0bd82a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,13 +114,13 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | -| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | +| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); late audio (#504, #335); #329 and #554 are closed; no benchmark against After Effects | @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 24% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 7, effects in Simplified Chinese and Japanese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | diff --git a/docs/testing.md b/docs/testing.md index 67d622fd9..d556fdc4f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,6 +5,10 @@ cargo test --workspace # everything cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, wasm ``` +The `dev` profile keeps debug info to line tables (fast builds, readable backtraces). To debug +interactively — local variables, parameters, column breakpoints — build with the `dev-full` +profile: `cargo run --profile dev-full -p effectcraft -- --demo` (see `CONTRIBUTING.md`). + `cargo xtask ci` builds in the dev profile and runs the tests with `cargo nextest run` (plus `cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. It then runs `missing_translations_report` with `--nocapture`, which prints the interface strings each diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 000000000..2c3add101 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,64 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/effectcraft. The file path is the URL: +`troubleshooting/gpu-not-used.md` → `/docs/effectcraft/troubleshooting/gpu-not-used`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using EffectCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install EffectCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code, a release or an issue. +- Call the product "EffectCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths use the menu text from + `crates/engine/src/menus.rs`. +- Shortcuts: the code writes `Cmd`, which is Ctrl on Windows and Linux. Write Ctrl for Windows. +- Windows 11 is the only Windows version to name. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: EffectCraft does not use my graphics card + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.8.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/effectcraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. Problems and bug reports go in the +[issue tracker](https://github.com/storytold/effectcraft/issues). diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/after-effects-projects.md b/docs/user/faq/after-effects-projects.md new file mode 100644 index 000000000..792aa7cc1 --- /dev/null +++ b/docs/user/faq/after-effects-projects.md @@ -0,0 +1,22 @@ +--- +title: Can EffectCraft open After Effects projects? +description: EffectCraft cannot open .aep or .aepx files, and After Effects plug-ins do not run. It saves .ecproj files and imports Lottie. +order: 10 +verified: 0.9.2 +--- + +No. EffectCraft cannot open After Effects projects (`.aep` or `.aepx`), and After Effects plug-ins do not run in it. + +## What it opens instead + +- Its own projects, saved as `.ecproj`. These are readable, versioned JSON files. +- Lottie animations, with **File ▸ Import ▸ Lottie...**. Each opens as a new composition. +- Photoshop and PDF or Illustrator files, imported as footage or as a composition. See [Import footage and image sequences](../guides/import-footage.md). + +## What carries over + +Expressions are JavaScript with the After Effects object model, such as `wiggle`, `loopOut` and `thisComp.layer("...")`. The panels, shortcuts and effect parameter names follow After Effects, so the skills carry over. + +## Status + +Reading `.aep` files is the largest known gap between EffectCraft and After Effects. It is listed in the project's [gap list](https://github.com/storytold/effectcraft/blob/main/docs/gaps.md), and the maintainers have not yet decided how to approach it. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 000000000..75b27177f --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Short answers about EffectCraft. +order: 40 +--- + +- [Can EffectCraft open After Effects projects?](after-effects-projects.md) +- [Is EffectCraft free, and where does it run?](price-and-platforms.md) diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md new file mode 100644 index 000000000..40f93c60d --- /dev/null +++ b/docs/user/faq/price-and-platforms.md @@ -0,0 +1,24 @@ +--- +title: Is EffectCraft free, and where does it run? +description: EffectCraft is free and open source under MIT or Apache-2.0, and runs on Windows, macOS, Linux and in the browser. +order: 20 +verified: 0.9.2 +--- + +Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2.0, at your option. It has no telemetry, and it uses no network access unless you ask for it. + +## Where it runs + +| Platform | Builds | +|----------|----------------------------------------------------------------------------------------| +| Windows | x64, arm64 and 32-bit x86, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 deb, rpm, tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | + +See [Install EffectCraft](../getting-started/install.md) for the files. + +## How mature is it? + +EffectCraft is in alpha. The README calls macOS the most tested platform, and says Linux and Windows users have hit basic interaction bugs that are being fixed first. Bug reports in the [issue tracker](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 000000000..6a3cad148 --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,8 @@ +--- +title: Getting started +description: Install EffectCraft and make your first composition. +order: 10 +--- + +- [Install EffectCraft](install.md) +- [Make your first composition](../guides/make-a-composition.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 000000000..b2e77a5d9 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,58 @@ +--- +title: Install EffectCraft +description: Download EffectCraft for Windows 11, macOS or Linux, install it, or install it through ArtCraft Launcher. +order: 10 +verified: 0.9.2 +--- + +The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.9.2`. + +## Windows 11 + +1. Open the [latest release](https://github.com/storytold/effectcraft/releases/latest). +2. Download `effectcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). +3. Run the file and follow the installer. It asks where to install EffectCraft and offers a **Create a desktop shortcut** box. +4. Start **EffectCraft** from the Start menu. + +The release also has an `arm64` installer for Snapdragon and other ARM PCs. The installers are code-signed and install for all users. Later versions install into the same folder. + +To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` build has a portable zip too. + +There are also 32-bit `x86` builds, as an installer and a portable zip. + +## macOS + +1. Download `effectcraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs and is signed and notarized. +2. Open it and drag EffectCraft to Applications. + +The command-line tool is a separate download for macOS: `effectcraft-cli--macos-universal.zip`. + +## Linux + +Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. + +| File | How to use it | +|-----------------------------------------------|------------------------------------------| +| `effectcraft--linux-x86_64.AppImage` | Run `chmod +x` on the file, then run it. | +| `effectcraft--linux-x86_64.deb` | For Debian and Ubuntu. | +| `effectcraft--linux-x86_64.rpm` | For Fedora, RHEL and openSUSE. | +| `effectcraft--linux-x86_64.flatpak` | `flatpak install --user ` | +| `effectcraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +For RISC-V 64-bit there is a `.deb`, an `.rpm` and a `.tar.gz` (`riscv64` in the file name). They need glibc 2.39 or newer. There is also a FreeBSD x86_64 `.tar.gz`. + +## In the browser + +`effectcraft-web-.zip` is the web build. It is a static site that runs in a modern browser and needs to be hosted on a web server. Projects, media and settings stay in the browser's private storage and on your computer. + +## Install through ArtCraft Launcher + +ArtCraft Launcher lists EffectCraft. Click **Install** on its card. See [Install and update Craft apps](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps). + +## Check the download + +`SHA256SUMS.txt` on the release page lists a checksum for every file. + +## Next + +Open EffectCraft and make something: [Make your first composition](../guides/make-a-composition.md). If you only want to look around, choose **Open Demo Project** on the Home screen. diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md new file mode 100644 index 000000000..5459b21d9 --- /dev/null +++ b/docs/user/guides/add-an-effect.md @@ -0,0 +1,35 @@ +--- +title: Add an effect +description: Apply an effect such as Gaussian Blur to a layer and change its settings in Effect Controls. +order: 30 +verified: 0.9.2 +--- + +Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + +## Steps + +1. Select a layer in the Timeline. +2. Apply an effect in one of two ways: + - Choose it from the **Effect** menu. Effects are grouped by category, for example **Effect ▸ Blur & Sharpen ▸ Gaussian Blur**. + - Open **Window ▸ Effects & Presets** (Ctrl+5). Type in the search field, then double-click the effect. Dragging it onto a layer works too. +3. Open **Window ▸ Effect Controls** (F3), or choose **Effect ▸ Effect Controls**. The effect's settings are listed there. **Gaussian Blur** has **Blurriness**, **Blur Dimensions** and **Repeat Edge Pixels**. +4. Change a value. Click the stopwatch beside it to animate it, as in [Animate with keyframes](animate-with-keyframes.md). + +If no layer is selected when you double-click an effect, EffectCraft shows **Select a layer to apply to**. Select a layer and try again. + +## Remove or reuse effects + +- **Effect ▸ Last Effect** applies the effect you used last. +- **Effect ▸ Remove All** takes every effect off the selected layers. +- In **Effects & Presets**, click the star on an effect to add it to Favorites. Recently used effects are listed too. + +## Layer styles + +For drop shadows, glows and bevels, use **Layer ▸ Layer Styles**. It lists **Drop Shadow**, **Inner Shadow**, **Outer Glow**, **Inner Glow**, **Bevel and Emboss** and **Satin**, among others. + +## Next + +[Render a video](render-a-video.md). diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md new file mode 100644 index 000000000..53ef7882e --- /dev/null +++ b/docs/user/guides/animate-with-keyframes.md @@ -0,0 +1,35 @@ +--- +title: Animate with keyframes +description: Fly a layer in with two Position keyframes and smooth the motion with Easy Ease. +order: 20 +verified: 0.9.2 +--- + +Click the stopwatch beside a property to start animating it, move the current-time indicator, then change the value. The second keyframe appears by itself. Select both keyframes and press F9 to ease the motion. + +You need a composition with a layer. See [Make your first composition](make-a-composition.md). + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Steps + +1. In the Timeline, twirl the layer open, then open **Transform**. +2. Set the starting value. For a layer that flies in from the left, scrub or type a **Position** that is outside the frame. +3. Click the stopwatch beside **Position**. It turns blue and records a keyframe at the current time. +4. Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler. +5. Drag the layer to its final place in the Composition viewer, or scrub the **Position** value. Because the stopwatch is on, a second keyframe appears by itself. +6. Select both keyframes (drag a box around them, or click the **Position** name) and press F9. This is **Animation ▸ Keyframe Assistant ▸ Easy Ease**. The layer now glides in and settles. +7. Press the Spacebar, or use Play in the Preview panel, to watch it. Press it again to stop. + +## Other things you can do + +- Click the keyframe button in the layer's property row to add or remove a keyframe at the current time. Its tooltip reads **Add or remove keyframe**. +- Choose **Animation ▸ Keyframe Assistant ▸ Easy Ease In** (Shift+F9) or **Easy Ease Out** to ease one side only. +- Choose **Animation ▸ Toggle Hold Keyframe** for a keyframe that jumps instead of moving. +- Choose **Animation ▸ Keyframe Interpolation...** or **Keyframe Velocity...** to set the curve exactly. +- Hold Alt and click a stopwatch to add an expression. +- The Graph Editor shows value and speed graphs with draggable handles. + +## Next + +[Add an effect](add-an-effect.md). diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md new file mode 100644 index 000000000..2cd754bd2 --- /dev/null +++ b/docs/user/guides/import-footage.md @@ -0,0 +1,49 @@ +--- +title: Import footage and image sequences +description: Bring video, images and numbered image sequences into an EffectCraft project, and change how footage is read. +order: 50 +verified: 0.9.2 +--- + +Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Import files + +1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. +2. Photoshop and PDF or Illustrator files first ask whether to import them as footage or as a composition. +3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. If no composition is open, dropping it on the empty viewer makes a new composition from it. + +You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. A dropped Photoshop file asks how to import it, as **File ▸ Import ▸ File...** does. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. + +## Import an image sequence + +1. Choose **File ▸ Import ▸ File...** and pick any one numbered file of the run. +2. In the **Import Image Sequence** dialog, check the run it found. It also lists missing frames. +3. Tick the sequence option, which is named after the file type, for example **PNG Sequence**. When it is off, the files import as separate stills. It is off by default. +4. Set **Frame rate (fps)**. The default comes from **Sequence Footage** on the Import page of the preferences (**Edit ▸ Preferences ▸ Import...**; on macOS: **EffectCraft ▸ Settings... ▸ Import...**), which is 30 fps. +5. Confirm. The run arrives as one footage item named like `shot_[0001-0250].png`. + +**Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. + +Stills in PNG, JPEG, TIFF, OpenEXR, BMP, WebP, Targa (`.tga`), Radiance HDR (`.hdr`), DPX and Cineon (`.cin`) can form a sequence. PSD, SVG and PDF files import one by one. + +If the numbering skips a frame, EffectCraft shows colour bars for it. Put the missing files in the folder and choose **File ▸ Reload Footage** to fill the gap. + +## Change how footage is read + +Select the footage in the Project panel and choose **File ▸ Interpret Footage ▸ Main...** (Ctrl+Alt+G). The dialog has **Alpha**, **Assume this frame rate** and **Start Timecode**, among others. A change applies everywhere the footage is used. + +For example, a 250-frame sequence at 25 fps lasts 10 seconds. At 24 fps it lasts 10.4 seconds. + +## Replace or reload footage + +- **File ▸ Replace Footage ▸ File...** (Ctrl+H) points the item at another file. +- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder. + +If footage shows as missing, see [Footage is missing or offline](../troubleshooting/footage-missing.md). + +## Linux with Wayland + +Dragging files onto the window does not work on native Wayland sessions. Use **File ▸ Import ▸ File...** instead. diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 000000000..d8182d069 --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Guides +description: Step-by-step guides for the main tasks in EffectCraft. +order: 20 +--- + +- [Make your first composition](make-a-composition.md) +- [Animate with keyframes](animate-with-keyframes.md) +- [Add an effect](add-an-effect.md) +- [Render a video](render-a-video.md) +- [Import footage and image sequences](import-footage.md) diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md new file mode 100644 index 000000000..5dcd8702b --- /dev/null +++ b/docs/user/guides/make-a-composition.md @@ -0,0 +1,34 @@ +--- +title: Make your first composition +description: Create a composition, add a text layer and save the project as an .ecproj file. +order: 10 +verified: 0.9.2 +--- + +Choose **Composition ▸ New Composition...**, set the size, frame rate and duration, click **OK**, then add layers from the **Layer ▸ New** menu. Shortcuts below use Ctrl on Windows and Linux. On macOS, use Cmd. + +## Steps + +1. Choose **Composition ▸ New Composition...** (Ctrl+N). On the Home screen, click **New Composition** instead. +2. In the dialog, type a **Composition Name**. The default is `Comp 1`. +3. Pick a **Preset**, or set **Width**, **Height**, **Frame Rate** and **Duration**. The presets include `HDTV 1080 24`, `UHD 4K 25` and `Social Media Square · 1080x1080 · 30 fps`. +4. Click **OK**. The composition opens in the Composition panel and the Timeline. +5. Choose **Layer ▸ New ▸ Text** and type your text. A text layer appears at the top of the Timeline, and its name follows what you type. +6. Add more layers from **Layer ▸ New**: **Solid...**, **Shape Layer**, **Null Object**, **Adjustment Layer**, **Light...** and **Camera...**. +7. Choose **File ▸ Save** to save the project. + +Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it also works with version control. + +## Change the settings later + +Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. + +To keep a size and frame rate for later, click the **+** button next to **Preset** (**Save Preset…**) and name it. Your presets are listed above the built-in ones, in this dialog and in **New Composition...**. To remove one, select it and click the bin button (**Delete Preset**). + +## Learn in the app + +The in-app tutorial **Animate a title** walks through a similar title animation. To open it, choose **Help ▸ In-App Tutorials...**. + +## Next + +[Animate with keyframes](animate-with-keyframes.md). diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md new file mode 100644 index 000000000..42fa5d0a5 --- /dev/null +++ b/docs/user/guides/render-a-video.md @@ -0,0 +1,52 @@ +--- +title: Render a video +description: Add a composition to the Render Queue, choose a format such as H.264, pick where to save and click Render. +order: 40 +verified: 0.9.2 +--- + +Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Steps + +1. Select the composition in the Project panel or open it. +2. Choose **Composition ▸ Add to Render Queue** (Ctrl+M). You can also drag compositions onto the Render Queue panel. Open the panel with **Window ▸ Render Queue** (Ctrl+Alt+0). +3. The queue lists the item with its **Comp Name**, **Status**, **Started** and **Render Time**. Twirl the item open to see **Render Settings:**, **Output Module:** and **Output To**. +4. Click **Output Module:** and choose a format. The menu entries read **Format: H.264**, **Format: QuickTime (ProRes)** and so on. +5. Click the file name next to **Output To** to choose where to save. Alt-click it to edit the name template instead. +6. Check that the **Render** tick box at the left of the item is on. It is on by default, and only ticked items render. +7. Click **Render**. The status changes to **Rendering** with a percentage. Click **Stop** to cancel. + +Tick **Notify** to get a sound and a notification when the queue finishes. + +## Formats + +The **Output Module:** menu lists the formats your build can export: + +| Menu entry | File type | +|--------------------------|-----------------------------| +| H.264 | `.mp4` | +| QuickTime (ProRes) | `.mov` | +| QuickTime (Uncompressed) | `.mov` | +| HEVC (H.265) | `.mp4` | +| AV1 | `.mp4` | +| WebM (VP9 + Opus) | `.webm` | +| PNG Sequence | `.png`, one file per frame | +| JPEG Sequence | `.jpg`, one file per frame | +| TIFF Sequence | `.tif`, one file per frame | +| OpenEXR Sequence | `.exr`, one file per frame | +| Animated GIF | `.gif` | +| WAV | `.wav`, audio only | +| AIFF | `.aif`, audio only | + +To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes), QuickTime (Uncompressed) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. + +## Render only part of a composition + +Click **Render Settings:** and choose **Time Span: Work Area Only** or **Time Span: Length of Comp**. The same menu sets **Quality: Best** or **Quality: Draft** and the resolution (**Resolution: Full**, **Half**, **Third** or **Quarter**). + +## Export a Lottie animation instead + +Choose **File ▸ Export ▸ Lottie JSON...** to write a composition as `.json` or `.lottie`. EffectCraft lists anything Lottie cannot express. To open one, choose **File ▸ Import ▸ Lottie...**. diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 000000000..05ed4993c --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,36 @@ +--- +title: EffectCraft +description: EffectCraft is a free, open-source app for motion graphics and visual effects, an alternative to Adobe After Effects. +verified: 0.9.2 +--- + +EffectCraft is a free, open-source app for animated titles, motion graphics and compositing. It is an alternative to Adobe After Effects. + +You build a composition from layers (solids, shapes, text, footage, other compositions), animate their properties with keyframes, add effects and render the result. The panels and shortcuts follow After Effects: Project, Composition, Timeline, Effect Controls, Effects & Presets and the Render Queue. It runs on macOS, Windows and Linux, and in the browser. + +EffectCraft is young and in alpha. It cannot open After Effects projects (`.aep`), and some things still behave differently from After Effects. See [Can EffectCraft open After Effects projects?](faq/after-effects-projects.md). + +## Start here + +- [Install EffectCraft](getting-started/install.md) +- [Make your first composition](guides/make-a-composition.md) +- [Animate with keyframes](guides/animate-with-keyframes.md) +- [Add an effect](guides/add-an-effect.md) +- [Render a video](guides/render-a-video.md) +- [Import footage and image sequences](guides/import-footage.md) + +## When something is wrong + +- [EffectCraft does not use my graphics card](troubleshooting/gpu-not-used.md) +- [Footage is missing or offline](troubleshooting/footage-missing.md) + +## Sections + +- [Getting started](getting-started/index.md) +- [Guides](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which version and which system you use. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md new file mode 100644 index 000000000..7507a2ad0 --- /dev/null +++ b/docs/user/troubleshooting/footage-missing.md @@ -0,0 +1,35 @@ +--- +title: Footage is missing or offline +description: Find missing footage in an EffectCraft project, relink it to a folder or replace it with another file. +order: 20 +verified: 0.9.2 +--- + +Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. + +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. + +## Symptoms + +- A footage item in the Project panel is flagged as missing after you open a project. +- EffectCraft shows a message that footage items are missing. + +## Fix + +1. Choose **File ▸ Dependencies ▸ Find Missing Footage**. EffectCraft selects every missing item in the Project panel and shows how many it found. +2. Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder that holds your files. EffectCraft searches that folder and its subfolders. +3. It relinks files whose names are unique and whose type, dimensions and codec match what the project saved. The whole relink is one undo step. +4. For anything left over, select the item and choose **File ▸ Replace Footage ▸ File...** (Ctrl+H), then pick the file yourself. This also covers files with duplicate names in the folder, which Relink leaves alone. +5. After you copy or render files into a folder, choose **File ▸ Reload Footage** (Ctrl+Alt+L) to read them again. + +Relinking keeps item names, interpretation and layer timing. It does not change your files. For image sequences, every saved frame must be in the new folder. Relink Missing Footage is not available in the browser version. + +## Why this happens + +A project stores where each footage file is. If you move or rename the files, or open the project on another computer, the project cannot find them. + +Projects built with the command-line tool or an agent can use relative paths such as `bg.mp4`. Before 0.8.0, the app looked for those in its own working directory instead of next to the project file. Since 0.8.0 it also looks next to the project file ([pull request #676](https://github.com/storytold/effectcraft/pull/676)). The reporter on macOS has not confirmed the fix yet ([issue #652](https://github.com/storytold/effectcraft/issues/652)). + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which system you use, whether the project came from another computer or from the command-line tool, and where the footage is stored. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md new file mode 100644 index 000000000..7c4fb29e0 --- /dev/null +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -0,0 +1,42 @@ +--- +title: EffectCraft does not use my graphics card +description: What to do when the report says GPU none (CPU compositing), the window picked another adapter, or GPU effects fail on Windows 11 with DirectX 12. +order: 10 +verified: 0.9.2 +--- + +Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences (**Edit ▸ Preferences ▸ Startup & Repair...**; on macOS: **EffectCraft ▸ Settings... ▸ Startup & Repair...**) and restart EffectCraft. + +## Symptoms + +- The report says `GPU: none (CPU compositing)` followed by a reason. +- The `Window graphics:` line names an adapter or backend you did not expect, for example an integrated or OpenGL adapter instead of your graphics card. + +## Fix + +1. Choose **Help ▸ System Compatibility Report...**. Copy the text. +2. Look at the `Window graphics:` line. If it shows the wrong adapter, continue. If it shows your card, go to **Still stuck?** below. +3. Choose **Edit ▸ Preferences ▸ Startup & Repair...**. On macOS, choose **EffectCraft ▸ Settings... ▸ Startup & Repair...**. +4. Set **Window Graphics** to **Automatic**. The other choice is **OpenGL (compatibility)**. +5. Close EffectCraft and start it again. The change applies from the next launch. +6. Open the report again and check that `Window graphics:` now names your card. + +One user whose GPU was not detected on 0.8.0 fixed it this way: they set the window graphics to Automatic, and EffectCraft started through Vulkan ([issue #596](https://github.com/storytold/effectcraft/issues/596)). + +If the window runs on your card but the report still says `GPU: none (CPU compositing)`, the reason after it says why the GPU compositor did not start. With **OpenGL (compatibility)**, compositing always runs on the CPU, because the GPU compositor needs DirectX 12, Vulkan, Metal or WebGPU. + +## Why this happens + +When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL by itself. That keeps the app usable. Setting **Window Graphics** to **Automatic** lets EffectCraft choose the graphics backend again. + +The **GPU Information...** button on the Previews page of the preferences names the adapter the GPU compositor runs on. When compositing runs on the CPU, it says why after `GPU compositing is off:`. It also shows the `Window graphics:` line. For a bug report, still send the whole System Compatibility Report. + +## Windows 11 with DirectX 12 + +On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is still open in 0.9.2 as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. + +The window can also take a long time to appear. On 0.8.0 with an RTX 5060 Ti, one user measured 69 seconds before the window showed on DirectX 12 without a working DXC shader compiler, against 3 seconds on Vulkan, which is what Automatic picked on that PC. If you close EffectCraft during that wait, the next launch uses OpenGL and has no GPU compositor until you set **Window Graphics** back to **Automatic** (steps above). So wait for the window on the first launch. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Include the whole System Compatibility Report, your graphics card and driver version, and whether you also have an integrated GPU. diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 000000000..0cf140136 --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,10 @@ +--- +title: Troubleshooting +description: Fixes for common EffectCraft problems. +order: 30 +--- + +- [EffectCraft does not use my graphics card](gpu-not-used.md) +- [Footage is missing or offline](footage-missing.md) + +Not listed? Open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues) or ask in [Discord](https://discord.gg/artcraft). Include the text from **Help ▸ System Compatibility Report...**. diff --git a/docs/web.md b/docs/web.md index c18f7e869..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"last"` (or no key) | the last visit's project; the demo project on a first visit | +| `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | + +The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown +value, is the default. The file is fetched once per page load; serve it with +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 6f6eadae5..9f4ea2185 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -18,6 +18,8 @@ // segment on that side. So a selected pair gets the whole curve, and a single key works too. // Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease // set on them wouldn't play. +// The built-in Linear preset uses Linear interpolation, including on unused endpoint sides +// so a fully linear key has a diamond icon; the other presets use Bezier. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -29,6 +31,7 @@ var MAX_NAME = 64; var MIN_INFLUENCE = 0.1; var MAX_SPEED = 100; + var LINEAR = KeyframeInterpolationType.LINEAR; var BEZIER = KeyframeInterpolationType.BEZIER; var HOLD = KeyframeInterpolationType.HOLD; @@ -178,16 +181,16 @@ // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). - function easeSide(p, k, out, eases) { + function easeSide(p, k, out, eases, interpolation) { var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); if (out) { p.setTemporalEaseAtKey(k, inEase, eases); - p.setInterpolationTypeAtKey(k, inType, BEZIER); + p.setInterpolationTypeAtKey(k, inType, interpolation); } else { p.setTemporalEaseAtKey(k, eases, outEase); - p.setInterpolationTypeAtKey(k, BEZIER, outType); + p.setInterpolationTypeAtKey(k, interpolation, outType); } } @@ -201,7 +204,7 @@ // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. - function applyCurve(c, mode) { + function applyCurve(c, mode, interpolation) { var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. @@ -214,16 +217,16 @@ continue; } var s = doIn && k > 1 ? slopes(p, k - 1) : null; - if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + if (s || (doIn && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: false, eases: s ? eases(s, c.inSpeed, c.inInfluence) : p.keyInTemporalEase(k) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; - if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (s || (doOut && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: true, eases: s ? eases(s, c.outSpeed, c.outInfluence) : p.keyOutTemporalEase(k) }); if (sides.length > before) keys++; } } if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases, interpolation); } finally { app.endUndoGroup(); } @@ -384,7 +387,10 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var r = applyCurve(c, m), n = r.eased; + // Linear is an interpolation type too, so its keys get diamond icons. Edited speeds + // make a custom Bezier curve even while Linear remains selected in the list. + var interpolation = list.selection && list.selection.text === "Linear" && c.outSpeed === 1 && c.inSpeed === 1 ? LINEAR : BEZIER; + var r = applyCurve(c, m, interpolation), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..915f6ef3e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791594108, + "narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6510408d147d8c5e6d76043e899e9251756f6a75", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..38d8aa87c --- /dev/null +++ b/flake.nix @@ -0,0 +1,106 @@ +{ + description = "EffectCraft — motion graphics and visual effects, a free alternative to After Effects, in pure Rust"; + + inputs = { + # EffectCraft needs edition 2024 and rustc ≥ 1.95, so the flake tracks unstable. Consumers on a + # release channel can retarget it: `inputs.effectcraft.inputs.nixpkgs.follows = "nixpkgs";` + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { + self, + nixpkgs, + }: + let + inherit (nixpkgs) lib; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + # The AppStream metadata carries a release date. The flake source's own commit date is + # reproducible, unlike "now"; a tarball or dirty tree without one falls back to the epoch. + buildDate = + let + d = self.lastModifiedDate or ""; + in + if builtins.stringLength d >= 8 then + "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}" + else + "1970-01-01"; + + packageArgs = { + inherit buildDate; + }; + + forAllSystems = f: lib.genAttrs systems (system: f (import nixpkgs { inherit system; }) system); + in + { + packages = forAllSystems ( + pkgs: _: + let + effectcraft = pkgs.callPackage ./nix/package.nix packageArgs; + in + { + inherit effectcraft; + default = effectcraft; + } + ); + + # `nixpkgs.overlays = [ inputs.effectcraft.overlays.default ]` → `pkgs.effectcraft` everywhere. + overlays.default = final: _prev: { + effectcraft = final.callPackage ./nix/package.nix packageArgs; + }; + + apps = forAllSystems ( + _: system: { + default = { + type = "app"; + program = lib.getExe self.packages.${system}.effectcraft; + }; + } + ); + + devShells = forAllSystems ( + pkgs: system: + let + effectcraft = self.packages.${system}.effectcraft; + in + { + default = pkgs.mkShell ({ + # The package's own native inputs, so `cargo build` in the shell needs no extra setup. + inputsFrom = [ effectcraft ]; + packages = with pkgs; [ + cargo + rustc + clippy + rustfmt + rust-analyzer + ]; + # `cargo run` dlopens the Vulkan loader, libwayland and libxkbcommon like the packaged binary. + LD_LIBRARY_PATH = lib.makeLibraryPath effectcraft.passthru.runtimeLibs; + RUST_BACKTRACE = "1"; + }); + } + ); + + checks = forAllSystems ( + pkgs: system: { + effectcraft = self.packages.${system}.effectcraft; + + # `nix fmt` formats the flake; this keeps it formatted. + formatting = + pkgs.runCommand "effectcraft-nix-formatting" { nativeBuildInputs = [ pkgs.nixfmt ]; } + '' + nixfmt --check ${./flake.nix} ${./nix} + touch $out + ''; + } + ); + + formatter = forAllSystems (pkgs: _: pkgs.nixfmt); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..51b636c60 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,200 @@ +# EffectCraft — the desktop app (`effectcraft`) and the CLI/MCP server (`effectcraft-cli`), built from this +# repository: +# +# nix build # → ./result/bin/effectcraft, ./result/bin/effectcraft-cli +# +# Kept in step with the release workflow (.github/workflows/release.yml) and packaging/: +# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump. The +# git sources (storytold/craft-libs, storytold/filmcraft) are fetched at the revision the lock file pins; +# * the desktop file, hicolor icons, MIME types and AppStream metadata are the same files the +# .deb/.rpm ship (packaging/linux/), so `apt` and NixOS users see one identical EffectCraft; +# * `doCheck` is off: the test suite needs corpora and a GPU the Nix sandbox cannot provide +# (`cargo xtask ci` is the gate). `pkgs.effectcraft.overrideAttrs { doCheck = true; }` runs it. +# +# Everything in the product is pure Rust, so this needs no build system beyond cargo plus the +# libraries winit/wgpu `dlopen` at run time (see `runtimeLibs`). +# +# Desktop sessions: the window opens natively on Wayland (GNOME/Mutter, Hyprland and other +# wlroots-style compositors) and falls back to X11/XWayland. File dialogs go through +# xdg-desktop-portal, so the session needs a portal backend: GNOME ships one +# (`xdg-desktop-portal-gnome`); on Hyprland enable +# xdg.portal = { enable = true; extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; }; +# next to `programs.hyprland.enable = true;`. +{ + lib, + stdenv, + rustPlatform, + pkg-config, + makeWrapper, + # Windowing/GPU libraries: needed at build time by the crates that link them, at run time by the + # ones that open them with dlopen() (see `runtimeLibs`), and by the dev shell (flake.nix). + wayland, + wayland-scanner, + libxkbcommon, + libx11, + libxcb, + libxcursor, + libxi, + libxrandr, + vulkan-loader, + libglvnd, + # rfd opens libdbus-1 at run time to reach the xdg-desktop-portal file chooser (Open/Save dialogs). + dbus, + # Audio: cpal links ALSA, which reaches PipeWire/PulseAudio through the ALSA plug-ins. + alsa-lib, + # Validates the desktop file and the AppStream metadata we install (as CI does). + desktop-file-utils, + appstream, + # Adds the driver search path (/run/opengl-driver/lib) to the binaries' RPATH so wgpu finds the + # installed Vulkan/GL driver on NixOS. Optional: harmless on nixpkgs without it. + autoAddDriverRunpath ? null, + # Release date for the AppStream metadata (YYYY-MM-DD); the flake derives it from its own commit. + buildDate ? "1970-01-01", +}: + +let + root = ../.; + + # The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml. + version = (builtins.fromTOML (builtins.readFile (root + "/Cargo.toml"))).workspace.package.version; + + isLinux = stdenv.hostPlatform.isLinux; + + # AppStream id: also the desktop file name, and the icon name in share/icons/hicolor. + appId = "ai.storyteller.effectcraft"; + + # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at + # them. winit loads libwayland-client, libxkbcommon(-x11) and the X11 libraries; wgpu loads the + # Vulkan loader or libEGL (libglvnd); rfd loads libdbus-1 for the file chooser portal. NixOS patches the loader to find the drivers in + # /run/opengl-driver. The wrapper below points LD_LIBRARY_PATH here. + runtimeLibs = lib.optionals isLinux [ + libxkbcommon + wayland + libx11 + libxcb + libxcursor + libxi + libxrandr + vulkan-loader + libglvnd + dbus + ]; + + # The two native binaries. The wasm app (apps/effectcraft-web) is built by `cargo xtask`, not here. + binaries = [ + "effectcraft" + "effectcraft-cli" + ]; + +in +rustPlatform.buildRustPackage { + pname = "effectcraft"; + inherit version; + + src = lib.cleanSourceWith { + src = lib.cleanSource root; + # The Nix files are not part of the build: editing them must not rebuild any Rust. + filter = + path: _type: + !(builtins.elem (baseNameOf path) [ + "flake.nix" + "flake.lock" + "nix" + ]); + }; + + cargoLock = { + lockFile = root + "/Cargo.lock"; + # The git dependencies are pinned by revision in Cargo.lock. + allowBuiltinFetchGit = true; + }; + + # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`). + cargoBuildFlags = [ + "--locked" + "-p" + "effectcraft" + "-p" + "effectcraft-cli" + ]; + + doCheck = false; + + strictDeps = true; + + nativeBuildInputs = [ + pkg-config + ] + ++ lib.optionals isLinux [ + makeWrapper + wayland-scanner + desktop-file-utils + appstream + ] + ++ lib.optionals (isLinux && autoAddDriverRunpath != null) [ autoAddDriverRunpath ]; + + buildInputs = lib.optionals isLinux [ + wayland + libxkbcommon + libx11 + libxcb + libxcursor + libxi + libxrandr + alsa-lib + ]; + + postInstall = '' + # Desktop integration: byte-for-byte the files the .deb/.rpm/.AppImage install. + install -Dm644 packaging/linux/${appId}.desktop $out/share/applications/${appId}.desktop + install -Dm644 packaging/linux/${appId}.mime.xml $out/share/mime/packages/${appId}.xml + cp -r assets/app-icon/hicolor $out/share/icons/ + install -d $out/share/metainfo + substitute packaging/linux/${appId}.metainfo.xml.in \ + $out/share/metainfo/${appId}.metainfo.xml \ + --subst-var-by VERSION ${version} \ + --subst-var-by DATE ${buildDate} + + # Licences for everything embedded in the binaries. + doc=$out/share/doc/effectcraft + for f in README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md assets/ATTRIBUTION.md; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + for f in assets/fonts/OFL-*.txt; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + '' + + lib.optionalString isLinux '' + desktop-file-validate $out/share/applications/${appId}.desktop + XDG_CACHE_HOME=$TMPDIR/.cache appstreamcli validate --no-net \ + $out/share/metainfo/${appId}.metainfo.xml + + # makeWrapper keeps the binaries working when the libs above are only dlopen'd. + for bin in ${lib.concatStringsSep " " (map (b: "$out/bin/" + b) binaries)}; do + wrapProgram "$bin" --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" + done + ''; + + passthru = { + # Shared with the dev shell so `cargo run` inside `nix develop` finds the same dlopen'd libs. + inherit runtimeLibs; + }; + + meta = { + description = "Motion graphics and visual effects application"; + longDescription = '' + EffectCraft is a motion graphics and visual effects application: compositions, keyframes, + effects and rendering. It ships with effectcraft-cli, a headless renderer, command runner and + MCP server for AI agents. + ''; + homepage = "https://getartcraft.com/apps/effectcraft"; + license = with lib.licenses; [ + mit + asl20 + ]; + sourceProvenance = with lib.sourceTypes; [ fromSource ]; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + # `nix run` and the desktop entry both start the GUI. + mainProgram = "effectcraft"; + }; +} diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 3e3b88948..99d8c0444 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,23 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + cs + de + es + fr + pt-BR + ru + uk + ka + ja + zh-Hans + zh-Hant + CFBundleDisplayName EffectCraft CFBundleName diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index afc767de2..0f8072840 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,3 +13,4 @@ serde_json = { workspace = true } chrono = { workspace = true } tempfile = "3" +pkg-config = "0.3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da8026d78..ad03cada7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,6 +407,62 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can +/// check the package names without needing a machine that is actually missing them. +#[cfg(target_os = "linux")] +fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String { + let mut hint = String::from( + "Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ + The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ + pkg-config for `alsa`; without the package the build dies minutes later with an\n\ + unrelated-looking linker error.\n\ + \n\ + Fedora: sudo dnf install alsa-lib-devel\n\ + Debian/Ubuntu: sudo apt install libasound2-dev\n\ + Arch: sudo pacman -S alsa-lib\n\ + \n\ + Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ + PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ + libasound (usually `libasound.so.2`); every CI run will need that variable set.", + ); + if let Some(stderr) = pkg_config_stderr { + hint.push_str("\n\npkg-config said: "); + hint.push_str(stderr); + } + hint +} + +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA +/// pkg-config package before the build dies minutes later at link time. The probe goes through +/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path +/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config` +/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`. +/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately, +/// so a machine with only `pkgconf` does not fail a build that would succeed. +#[cfg(target_os = "linux")] +fn audio_backend() -> Result<(), String> { + // cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would + // only clutter the ci log. + match pkg_config::Config::new().cargo_metadata(false).probe("alsa") { + Ok(_) => Ok(()), + Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr))) + } + Err(e) => Err(format!( + "The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \ + or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \ + dependency performs." + )), + } +} + +#[cfg(not(target_os = "linux"))] +fn audio_backend() -> Result<(), String> { + Ok(()) +} + /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, /// which nextest doesn't run. /// @@ -438,7 +494,8 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 7] = [ + let stages: [(&str, Stage); 8] = [ + ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), @@ -459,6 +516,7 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), + "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -469,7 +527,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -484,3 +542,22 @@ fn main() -> ExitCode { pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::audio_backend_missing_hint; + + #[test] + fn alsa_missing_names_the_package_for_each_distro() { + let e = audio_backend_missing_hint(None); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { + assert!(e.contains(want), "hint lost `{want}`:\n{e}"); + } + } + + #[test] + fn alsa_missing_appends_pkg_config_s_own_detail() { + let e = audio_backend_missing_hint(Some("Package 'alsa' not found")); + assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}"); + } +} diff --git a/xtask/tests/it/audio_probe.rs b/xtask/tests/it/audio_probe.rs new file mode 100644 index 000000000..d984c7637 --- /dev/null +++ b/xtask/tests/it/audio_probe.rs @@ -0,0 +1,126 @@ +//! The Linux audio-backend preflight against fake pkg-config tools. +//! +//! The probe resolves its executable through the pkg-config crate, which reads the environment, +//! so the fake-tool scenarios run the real `xtask audio-backend` binary in a child process with a +//! controlled environment: `Command::env` changes only the child's environment, so the tests need +//! no global env mutation and stay parallel-safe. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A scratch directory holding the fake tools for one scenario: `dir` is the child's `PATH`, +/// `off_path` holds executables reachable only by an explicit `*_PKG_CONFIG` override. +struct FakeTools { + dir: PathBuf, + off_path: PathBuf, +} + +impl FakeTools { + fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!("effectcraft-xtask-audio-{tag}-{}", std::process::id())); + let dir = base.join("bin"); + let off_path = base.join("off-path"); + std::fs::create_dir_all(&dir).expect("create PATH dir"); + std::fs::create_dir_all(&off_path).expect("create off-PATH dir"); + Self { dir, off_path } + } + + /// Write a plain (not executable) file. + fn file(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + let path = if on_path { self.dir.clone() } else { self.off_path.clone() }.join(name); + std::fs::write(&path, body).expect("write fake tool"); + path + } + + /// Write an executable shell script. + fn script(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(on_path, name, body); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod fake tool"); + path + } + + /// A fake `pkgconf` that answers as alsa 1.2.11 linking -lasound. + fn working_pkgconf(&self, on_path: bool) -> PathBuf { + self.script(on_path, "pkgconf", "#!/bin/sh\ncase \" $* \" in\n *\" --modversion \"*) echo 1.2.11 ;;\n *) echo -lasound ;;\nesac\n") + } +} + +impl Drop for FakeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + let _ = std::fs::remove_dir_all(&self.off_path); + } +} + +/// Run the built `xtask audio-backend` with `dir` as the only `PATH`, the given variables set, +/// and every variable the probe reads otherwise cleared, so a developer's own environment +/// cannot leak into the scenario. +fn run_audio_backend(tools: &FakeTools, set: &[(&str, &OsStr)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xtask")) + .arg("audio-backend") + .env("PATH", &tools.dir) + .env_remove("PKG_CONFIG") + .env_remove("PKG_CONFIG_PATH") + .env_remove("PKG_CONFIG_LIBDIR") + .env_remove("PKG_CONFIG_SYSROOT_DIR") + .env_remove("PKG_CONFIG_ALLOW_CROSS") + .env_remove("ALSA_NO_PKG_CONFIG") + .env_remove("HOST_PKG_CONFIG") + .env_remove("TARGET_PKG_CONFIG") + .env_remove("HOST") + .env_remove("TARGET") + .envs(set.iter().copied()) + .output() + .expect("run xtask audio-backend") +} + +#[test] +fn a_host_with_only_pkgconf_still_finds_alsa() { + // The reviewed case: PKG_CONFIG unset, no pkg-config on PATH, only pkgconf. The pkg-config + // crate alsa-sys uses falls back to pkgconf, so the stage must stay green. + let tools = FakeTools::new("pkgconf-only"); + tools.working_pkgconf(true); + let out = run_audio_backend(&tools, &[]); + assert!(out.status.success(), "pkgconf-only host must pass:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn a_probe_that_runs_and_says_no_names_the_package() { + let tools = FakeTools::new("says-no"); + tools.script(true, "pkg-config", "#!/bin/sh\necho 'Package alsa was not found' >&2\nexit 1\n"); + let out = run_audio_backend(&tools, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config"] { + assert!(stderr.contains(want), "hint lost `{want}`:\n{stderr}"); + } + assert!(stderr.contains("not found"), "pkg-config's own detail should be appended:\n{stderr}"); +} + +#[test] +fn a_probe_that_cannot_run_is_not_alsa_missing() { + // PKG_CONFIG names a file that is not an executable; with PKG_CONFIG set the crate does not + // fall back to pkgconf, so the lookup fails to launch — reported separately from "missing". + let tools = FakeTools::new("cannot-run"); + let broken = tools.file(true, "not-an-executable", "this is not a program\n"); + let out = run_audio_backend(&tools, &[("PKG_CONFIG", broken.as_os_str())]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("could not"), "expected the launch-failure error:\n{stderr}"); + assert!(!stderr.contains("alsa-lib-devel"), "must not read as ALSA missing:\n{stderr}"); +} + +#[test] +fn a_host_scoped_override_selects_that_executable() { + // With TARGET and HOST set (build-script style), HOST_PKG_CONFIG wins over the PATH lookup, + // and here it is the only working tool — so success proves the override was used. + let tools = FakeTools::new("scoped"); + let good = tools.working_pkgconf(false); + let out = run_audio_backend( + &tools, + &[("HOST", OsStr::new("x86_64-unknown-linux-gnu")), ("TARGET", OsStr::new("x86_64-unknown-linux-gnu")), ("HOST_PKG_CONFIG", good.as_os_str())], + ); + assert!(out.status.success(), "HOST_PKG_CONFIG override must be honored:\n{}", String::from_utf8_lossy(&out.stderr)); +} diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs new file mode 100644 index 000000000..f2c41c738 --- /dev/null +++ b/xtask/tests/it/main.rs @@ -0,0 +1,4 @@ +//! xtask's integration tests: one binary, modules in `tests/it/`. + +#[cfg(target_os = "linux")] +mod audio_probe;