mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
Merge main into fix/expression-editor-autofocus
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
Generated
+192
-36
@@ -747,6 +747,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.5.1"
|
||||
@@ -962,6 +968,20 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1143,7 +1163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
@@ -1168,7 +1188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1180,14 +1200,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1203,7 +1223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1211,7 +1231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1228,8 +1248,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-geom",
|
||||
@@ -1261,7 +1282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1291,7 +1312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1308,7 +1329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1316,7 +1337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1338,14 +1359,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1370,7 +1391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1389,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1401,8 +1422,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1418,8 +1440,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
@@ -1440,12 +1463,13 @@ dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1457,14 +1481,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1476,8 +1500,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
@@ -1485,12 +1510,13 @@ dependencies = [
|
||||
"kurbo",
|
||||
"miniz_oxide 0.8.9",
|
||||
"skrifa",
|
||||
"wasm-bindgen-test",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1502,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1515,14 +1541,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1531,7 +1557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1553,7 +1579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1570,7 +1596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
@@ -1579,7 +1605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1591,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1603,7 +1629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1611,7 +1637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1623,7 +1649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
@@ -1655,11 +1681,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -2678,6 +2704,40 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -3167,6 +3227,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3311,6 +3381,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -3759,6 +3838,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -4773,6 +4858,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5156,6 +5252,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -5298,6 +5400,21 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -5462,6 +5579,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
@@ -6469,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+9
-1
@@ -4,13 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.2"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/effectcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -91,6 +93,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
|
||||
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
@@ -204,3 +209,6 @@ opt-level = 3
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" }
|
||||
|
||||
@@ -15,3 +15,8 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
|
||||
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
@@ -154,6 +158,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
| 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-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
|
||||
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
|
||||
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
|
||||
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
@@ -106,6 +108,8 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
|
||||
render/render-frame --template T.ectemplate [--values V.json] render one template instance
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
@@ -189,6 +193,7 @@ const FLAGS: &[&str] = &[
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
"--progress",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
@@ -263,8 +268,18 @@ fn value_arg(s: &str) -> Value {
|
||||
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
|
||||
}
|
||||
|
||||
/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a
|
||||
/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or
|
||||
/// write another file than the one named, so such an argument is a usage error naming it.
|
||||
fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<String>, String> {
|
||||
raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
};
|
||||
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
|
||||
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
@@ -493,6 +508,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(e));
|
||||
}
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
@@ -515,6 +533,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
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}")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
}
|
||||
@@ -637,6 +658,21 @@ fn positional<const N: usize>(args: &Args, usage: &str) -> Result<[String; N], F
|
||||
Ok(std::array::from_fn(|i| args.pos[i].clone()))
|
||||
}
|
||||
|
||||
/// `renderQueue.render` reports a failed item in its result, not as an error. Under `run`/`exec`
|
||||
/// that item fails the command (#1426), as it fails `render --queue`.
|
||||
fn failed_render(id: &str, r: &Value) -> Option<String> {
|
||||
if id != "renderQueue.render" {
|
||||
return None;
|
||||
}
|
||||
let failed: Vec<String> = r["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| Some(format!("{}: {}", i["compName"].as_str().unwrap_or("?"), i["status"].get("Failed")?.as_str().unwrap_or("failed"))))
|
||||
.collect();
|
||||
(!failed.is_empty()).then(|| failed.join("; "))
|
||||
}
|
||||
|
||||
fn with_saved(v: Value, saved: Option<String>) -> Value {
|
||||
match saved {
|
||||
None => v,
|
||||
@@ -660,11 +696,10 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let mut s = session(args)?;
|
||||
if args.opt("--template").is_some() {
|
||||
template::prepare(&mut s, args)?;
|
||||
} else {
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
};
|
||||
} else if let Some(p) = &args.project {
|
||||
s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
} else if args.flag("--demo") || !args.flag("--empty") {
|
||||
s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
}
|
||||
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
|
||||
if !args.flag("--queue") {
|
||||
@@ -742,8 +777,21 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
let lines = args.flag("--progress") && !tty;
|
||||
let (mut last_line, mut last_done) = (std::time::Instant::now(), u64::MAX);
|
||||
while s.is_rendering() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if lines
|
||||
&& last_line.elapsed() >= std::time::Duration::from_millis(250)
|
||||
&& let Some(p) = s.render_progress()
|
||||
&& p.done != last_done
|
||||
{
|
||||
last_line = std::time::Instant::now();
|
||||
last_done = p.done;
|
||||
let left = p.remaining.map_or_else(|| "-".to_string(), |r| format!("{r:.1}"));
|
||||
note!("progress {} {} {:.1} {left}", p.done, p.total, p.item_elapsed);
|
||||
}
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
@@ -1270,6 +1318,18 @@ mod tests {
|
||||
assert!(a.flag("--work-area") && a.flag("--json"));
|
||||
}
|
||||
|
||||
/// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming
|
||||
/// it, and Unicode arguments pass through unchanged.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_refused() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())];
|
||||
assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode");
|
||||
assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]);
|
||||
}
|
||||
|
||||
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
|
||||
#[test]
|
||||
fn every_option_read_is_declared() {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::io::Read;
|
||||
|
||||
use effectcraft_engine::{
|
||||
EngineError, Session,
|
||||
project::{ItemId, LayerId},
|
||||
project::{ItemId, LayerId, essential::EgKind},
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -36,7 +36,20 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
|
||||
{
|
||||
for input in ["--template", "--values"] {
|
||||
if let Some(path) = args.opt(input)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| {
|
||||
if path == output {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Distinct canonical paths can still name the same file through hard links.
|
||||
if let (Ok(input), Ok(output)) = (std::fs::metadata(&path), std::fs::metadata(&output)) {
|
||||
return input.dev() == output.dev() && input.ino() == output.ino();
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
{
|
||||
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
|
||||
}
|
||||
@@ -79,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(),
|
||||
project.fix_next_id();
|
||||
let mut check = Session::new();
|
||||
check.project = project.into();
|
||||
Ok(instantiate(&mut check, source, values))
|
||||
Ok(instantiate(&mut check, source, values, false))
|
||||
})
|
||||
.map_err(|e| Failure::Error(e.to_string()))?
|
||||
}
|
||||
@@ -92,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
|
||||
}
|
||||
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
||||
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
||||
instantiate(s, source, &values)
|
||||
instantiate(s, source, &values, true)
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
|
||||
/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`.
|
||||
fn media_path(value: &Value) -> Option<&str> {
|
||||
match value {
|
||||
Value::String(path) => Some(path.as_str()),
|
||||
Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement.
|
||||
fn is_media_control(s: &Session, source: u64, name: &str) -> bool {
|
||||
let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false };
|
||||
let all = eg.flat();
|
||||
let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name)));
|
||||
control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. }))
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import_media: bool) -> Result<(), Failure> {
|
||||
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
||||
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
||||
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
||||
@@ -132,6 +162,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
|
||||
// Let the wrapper carry the source composition's authored motion blur.
|
||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
||||
for (control, value) in values {
|
||||
if is_media_control(s, source, control) {
|
||||
let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?;
|
||||
// The preflight session cannot import files; the real run reports a bad path.
|
||||
if !import_media {
|
||||
continue;
|
||||
}
|
||||
let imported = execute(s, "file.import", json!({"paths": [path]}))?;
|
||||
if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) {
|
||||
return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}")));
|
||||
}
|
||||
let item = imported
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?;
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?;
|
||||
continue;
|
||||
}
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports.
|
||||
fn canonical(path: &std::path::Path) -> std::io::Result<PathBuf> {
|
||||
let path = std::fs::canonicalize(path)?;
|
||||
let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':'));
|
||||
Ok(plain.map(PathBuf::from).unwrap_or(path))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
|
||||
// directory through `current_dir()`, which is canonical, so compare canonical paths.
|
||||
std::fs::canonicalize(&d).unwrap_or(d).join(name)
|
||||
canonical(&d).unwrap_or(d).join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -160,6 +167,21 @@ fn unknown_options_are_usage_errors() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2)
|
||||
/// naming it, before anything runs: `std::env::args` panicked on one (exit 101).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_usage_errors() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
|
||||
let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}");
|
||||
assert!(!err.contains("panicked"), "{err}");
|
||||
assert!(out.stdout.is_empty(), "nothing ran");
|
||||
}
|
||||
|
||||
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
|
||||
/// still does its work and exits 0, without a panic (#167).
|
||||
#[test]
|
||||
@@ -260,7 +282,7 @@ fn render_out_is_relative_to_the_working_directory() {
|
||||
std::fs::create_dir_all(&proj).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
// current_dir resolves aliases such as macOS /var -> /private/var.
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = canonical(&root).unwrap();
|
||||
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
|
||||
let project = proj.join("x.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
@@ -462,6 +484,32 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
|
||||
std::fs::remove_dir_all(config).unwrap();
|
||||
}
|
||||
|
||||
/// `run`/`exec renderQueue.render` (#1426): a failed item is a command error, exit 1 like
|
||||
/// `render --queue`, not a success with the failure buried in the JSON.
|
||||
#[test]
|
||||
fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
let root = tmp("render-fails");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// The output's folder is a file, so the encoder cannot open the output.
|
||||
let blocker = root.join("not-a-folder");
|
||||
std::fs::write(&blocker, "x").unwrap();
|
||||
let out = blocker.join("out.gif");
|
||||
let add = json!({"comp": "T", "format": "gif", "output": out.to_str().unwrap()}).to_string();
|
||||
let project = root.join("q.ecproj");
|
||||
let steps = ["comp.new", r#"{"name":"T","width":16,"height":16,"duration":0.1}"#, "layer.newSolid", "{}", "renderQueue.add", &add];
|
||||
ok_json(&[&["run", "--empty", "--save-as", project.to_str().unwrap()][..], &steps[..]].concat());
|
||||
|
||||
let (code, v) = run_json(&[&["run", "--empty"][..], &steps[..], &["renderQueue.render", r#"{"wait":true}"#][..]].concat());
|
||||
assert_eq!(code, 1, "{v}");
|
||||
let e = v["error"].as_str().unwrap_or_else(|| panic!("{v}"));
|
||||
assert!(e.starts_with("renderQueue.render: T: "), "{e}");
|
||||
|
||||
let (code, v) = run_json(&["exec", "renderQueue.render", "--project", project.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{v}");
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
@@ -491,3 +539,131 @@ fn render_bitrate_applies_to_vp9_webm() {
|
||||
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Render uses the same empty/project/demo selection as the other CLI commands.
|
||||
#[test]
|
||||
fn render_empty_does_not_load_the_demo() {
|
||||
let root = tmp("render-empty");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let project = root.join("control.ecproj");
|
||||
let template = root.join("control.ectemplate");
|
||||
let project_path = project.to_str().unwrap();
|
||||
let template_path = template.to_str().unwrap();
|
||||
let build = r#"
|
||||
var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30);
|
||||
var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1);
|
||||
solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
|
||||
"#;
|
||||
ok_json(&["script", "--eval", build, "--save-as", project_path]);
|
||||
let params = json!({"comp": "Empty control", "path": template_path}).to_string();
|
||||
let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]);
|
||||
assert_eq!(exported["controls"], 1, "{exported}");
|
||||
let empty = ok_json(&["info", "--empty"]);
|
||||
assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}");
|
||||
|
||||
let render = |options: &[&str], name: &str| {
|
||||
let output = root.join(name);
|
||||
assert!(!output.exists());
|
||||
let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()];
|
||||
args.extend_from_slice(options);
|
||||
let reply = ok_json(&args);
|
||||
assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}");
|
||||
assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output);
|
||||
let bytes = std::fs::read(&output).unwrap();
|
||||
assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}");
|
||||
let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]]));
|
||||
(reply["rendered"][0]["comp"].clone(), dimensions)
|
||||
};
|
||||
// Defaults still render the demo, and an explicit demo wins over --empty.
|
||||
let demo = render(&[], "default.gif");
|
||||
assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo);
|
||||
// A supplied project wins over both flags, while template mode also accepts --empty.
|
||||
let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif");
|
||||
assert_eq!(project_result, (json!("Empty control"), (16, 9)));
|
||||
assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9));
|
||||
|
||||
let output = root.join("empty.gif");
|
||||
assert!(!output.exists());
|
||||
let (code, reply) =
|
||||
run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]);
|
||||
let output_exists = output.exists();
|
||||
assert!(!output_exists, "--empty must not render the demo into the requested output");
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters
|
||||
/// found (wgpu's own helper panics). Read once per process, hence a subprocess.
|
||||
#[test]
|
||||
fn gpu_adapter_name_mismatch_is_a_clean_error() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
let e = v["error"].as_str().unwrap();
|
||||
assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}");
|
||||
assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
|
||||
/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the
|
||||
/// adapter-name error (whether a GPU exists on this machine is not asserted).
|
||||
#[test]
|
||||
fn gpu_adapter_name_empty_is_unset() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", " ")
|
||||
.output()
|
||||
.unwrap();
|
||||
let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}");
|
||||
}
|
||||
|
||||
/// Renders two seconds of the demo at quarter resolution (long enough for several progress
|
||||
/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal.
|
||||
fn render_demo_stderr(name: &str, extra: &[&str]) -> String {
|
||||
let out = tmp(name);
|
||||
let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()];
|
||||
args.extend_from_slice(extra);
|
||||
let o = bin().args(&args).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
assert!(o.status.success(), "{args:?}: {stderr}");
|
||||
let _ = std::fs::remove_file(out);
|
||||
stderr
|
||||
}
|
||||
|
||||
/// `render --progress` with a piped stderr reports the render's frames as
|
||||
/// `progress <frames done> <frames total> <seconds elapsed> <seconds left|->` lines, one per
|
||||
/// change (#1778).
|
||||
#[test]
|
||||
fn render_progress_reports_frames_on_a_piped_stderr() {
|
||||
let stderr = render_demo_stderr("progress.gif", &["--progress"]);
|
||||
let mut last_done = None;
|
||||
let mut totals = vec![];
|
||||
for line in stderr.lines().filter(|l| l.starts_with("progress")) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
assert_eq!(fields.len(), 5, "{line}");
|
||||
let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}"));
|
||||
let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}"));
|
||||
let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}"));
|
||||
assert!(fields[4] == "-" || fields[4].parse::<f64>().is_ok_and(|left| left >= 0.0), "{line}: seconds left");
|
||||
assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}");
|
||||
assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}");
|
||||
last_done = Some(done);
|
||||
totals.push(total);
|
||||
}
|
||||
assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}");
|
||||
assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}");
|
||||
}
|
||||
|
||||
/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778).
|
||||
#[test]
|
||||
fn render_without_progress_writes_no_progress_lines() {
|
||||
let stderr = render_demo_stderr("no-progress.gif", &[]);
|
||||
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn template_output_alias_fixture(case: &str) -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
// Read the inputs before interpreting the subprocess status or error text.
|
||||
@@ -272,7 +272,8 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
);
|
||||
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
|
||||
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
|
||||
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
|
||||
assert_eq!(result.status.code(), Some(1), "an output alias must fail before rendering overwrites an input");
|
||||
serde_json::from_slice(&result.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,3 +653,117 @@ fn template_missing_output_does_not_replace_extracted_media() {
|
||||
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_template_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-template");
|
||||
let input = dir.join("lt.ectemplate");
|
||||
let output = dir.join("hard-linked-template.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-template.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-template.png would overwrite --template lt.ectemplate", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-values");
|
||||
let input = dir.join("values.json");
|
||||
let output = dir.join("hard-linked-values.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-values.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with
|
||||
/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`.
|
||||
fn media_template(case: &str) -> PathBuf {
|
||||
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||
let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap();
|
||||
let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||
let item = imported["result"]["items"][0].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||
let added = ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||
let layer = added["result"]["layer"].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string();
|
||||
ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]);
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Renders the template's frame with `values` and checks that the control shows `other.png`.
|
||||
fn assert_renders_the_replacement(dir: &PathBuf, values: &str) {
|
||||
std::fs::write(dir.join("values.json"), values).unwrap();
|
||||
ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]);
|
||||
let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0;
|
||||
assert!(
|
||||
(px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2,
|
||||
"{values}: replacement media rendered: {px:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_values_fill_a_media_replacement_control_with_a_file_path() {
|
||||
let dir = media_template("media-control");
|
||||
assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#);
|
||||
|
||||
// A control that is not given a path is rejected with a clear message.
|
||||
std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found
|
||||
/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787).
|
||||
#[test]
|
||||
fn template_media_control_takes_a_path_object_and_a_name_in_any_case() {
|
||||
let dir = media_template("media-forms");
|
||||
for values in [
|
||||
r#"{"Clip":{"path":"other.png"}}"#,
|
||||
r#"{"Clip":{"file":"other.png"}}"#,
|
||||
r#"{"Clip":{"media":"other.png"}}"#,
|
||||
r#"{"clip":"other.png"}"#,
|
||||
r#"{"CLIP":{"path":"other.png"}}"#,
|
||||
] {
|
||||
let _ = std::fs::remove_file(dir.join("out.png"));
|
||||
assert_renders_the_replacement(&dir, values);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A media path that cannot be imported fails the render with an error naming the control and
|
||||
/// the path, and writes no frame (#1787).
|
||||
#[test]
|
||||
fn template_media_control_reports_a_path_that_cannot_be_imported() {
|
||||
let dir = media_template("media-missing");
|
||||
std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
let error = reply["error"].as_str().unwrap();
|
||||
assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}");
|
||||
assert!(!dir.join("missing-out.png").exists(), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,26 @@ function req(r) {
|
||||
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
||||
}
|
||||
|
||||
// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has
|
||||
// committed. A request's success doesn't mean the write is stored: the transaction can still
|
||||
// abort afterwards (quota at commit, the browser closing), rolling it back, and a write
|
||||
// reported as saved then is never retried (#1242). A failed request aborts the transaction
|
||||
// with its error; an explicit abort leaves no error, hence the AbortError.
|
||||
function write(db, store, op) {
|
||||
return new Promise((res, rej) => {
|
||||
let tx = null;
|
||||
try {
|
||||
tx = db.transaction(store, "readwrite");
|
||||
tx.oncomplete = () => res();
|
||||
tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError"));
|
||||
op(tx.objectStore(store));
|
||||
} catch (e) {
|
||||
try { tx?.abort(); } catch {}
|
||||
rej(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function idb() {
|
||||
if (!self.indexedDB) throw new Error("no IndexedDB");
|
||||
const open = indexedDB.open(DB, 1);
|
||||
@@ -70,10 +90,10 @@ async function idb() {
|
||||
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
|
||||
},
|
||||
async put(key, data, modified) {
|
||||
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
|
||||
await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified }));
|
||||
},
|
||||
async del(key) {
|
||||
await req(tx("readwrite").delete(key));
|
||||
await write(db, STORE, (s) => s.delete(key));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -315,7 +335,15 @@ function spawn(base) {
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
|
||||
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
let w;
|
||||
try {
|
||||
w = idle.pop() ?? spawn(base);
|
||||
} catch (e) {
|
||||
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
|
||||
// fails like one whose worker didn't start.
|
||||
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
|
||||
return;
|
||||
}
|
||||
running.set(id, w);
|
||||
w.ready.then(() => {
|
||||
w.worker.onmessage = (e) => {
|
||||
@@ -380,7 +408,17 @@ const frameWorkers = [];
|
||||
/// GPU effects on it.
|
||||
export function frameWorkerStart(base, onMessage, gpu) {
|
||||
const index = frameWorkers.length;
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
} catch (e) {
|
||||
// The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The
|
||||
// slot stays, and the failure is reported like a worker that died, after the caller has
|
||||
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
|
||||
frameWorkers.push(null);
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
|
||||
return index;
|
||||
}
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
@@ -550,7 +588,7 @@ async function openFolder(handle, remember) {
|
||||
if (remember) {
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
|
||||
await write(db, "folders", (s) => s.put({ name, handle }));
|
||||
} catch (e) { console.info("media browser: cannot remember the folder", e); }
|
||||
}
|
||||
return { name, entries };
|
||||
@@ -602,7 +640,7 @@ export async function browseForget(name) {
|
||||
folders.delete(name);
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
|
||||
await write(db, "folders", (s) => s.delete(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,10 @@ impl Services for WebServices {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
|
||||
}
|
||||
/// The table's files (the default asks `std::fs`, which has no files on wasm32).
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
get(path).is_some()
|
||||
}
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -441,6 +441,18 @@ try {
|
||||
const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`);
|
||||
if (!comps) throw new Error("reopened project has no active comp");
|
||||
report.steps.reopen = { ...opened, activeComp: comps };
|
||||
|
||||
// Files the page holds (browser storage, no std::fs): a script opens one next to the project,
|
||||
// and the footage check finds the imported media instead of marking it missing.
|
||||
const hf = await js(`(async () => {
|
||||
const p = await effectcraft.addFile(new File(["from the page"], "script-data.txt"));
|
||||
await effectcraft.saveToBrowser("/host-files.ecproj");
|
||||
const run = await effectcraft.execute("script.run", {code: "var f = new File(" + JSON.stringify(p) + "); writeLn(f.open('r') ? f.read() : f.error);"});
|
||||
return {output: run.output, footage: await effectcraft.execute("footage.check", {wait: true})};
|
||||
})()`);
|
||||
report.steps.hostFiles = hf;
|
||||
check(hf.output === "from the page", `a script opens a file the page holds (${hf.output})`);
|
||||
check(hf.footage.checked > 0 && hf.footage.missing === 0, `stored footage isn't missing (${JSON.stringify(hf.footage)})`);
|
||||
report.steps.final = { screenshot: await shot("02-after") };
|
||||
|
||||
// Persistence: the session, imported media, settings and a project saved to browser storage
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no
|
||||
// browser or Wasm build needed:
|
||||
//
|
||||
// node apps/effectcraft-web/tests/storage.mjs
|
||||
//
|
||||
// A write is acknowledged only once its transaction has committed, not when its request
|
||||
// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be
|
||||
// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
globalThis.self = globalThis;
|
||||
|
||||
// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite
|
||||
// transaction commits once its requests are done, unless it was aborted (which rolls back its
|
||||
// writes). `fault` makes the next request do something else.
|
||||
let fault = null; // "abortAfterSuccess" | "requestError" | "throw"
|
||||
const databases = new Map();
|
||||
|
||||
class Request {
|
||||
constructor(tx) {
|
||||
this.transaction = tx;
|
||||
this.result = undefined;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
class Store {
|
||||
constructor(tx, rows) {
|
||||
this.tx = tx;
|
||||
this.rows = rows;
|
||||
}
|
||||
request(apply) {
|
||||
const tx = this.tx;
|
||||
if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError");
|
||||
const f = fault;
|
||||
fault = null;
|
||||
if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError");
|
||||
const r = new Request(tx);
|
||||
tx.pending++;
|
||||
setTimeout(() => {
|
||||
tx.pending--;
|
||||
if (tx.finished) return;
|
||||
if (f === "requestError") {
|
||||
r.error = new DOMException("the key already exists", "ConstraintError");
|
||||
const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
r.onerror?.(e);
|
||||
tx.onerror?.(e);
|
||||
if (!e.defaultPrevented) tx.fail(r.error);
|
||||
return;
|
||||
}
|
||||
r.result = apply(tx.staged);
|
||||
r.onsuccess?.({ target: r });
|
||||
if (f === "abortAfterSuccess") tx.abort();
|
||||
tx.maybeCommit();
|
||||
}, 0);
|
||||
return r;
|
||||
}
|
||||
put(v) {
|
||||
return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; });
|
||||
}
|
||||
delete(k) {
|
||||
return this.request((rows) => { rows.delete(k); });
|
||||
}
|
||||
get(k) {
|
||||
return this.request((rows) => rows.get(k));
|
||||
}
|
||||
getAll() {
|
||||
return this.request((rows) => [...rows.values()]);
|
||||
}
|
||||
}
|
||||
|
||||
class Transaction {
|
||||
constructor(db, name) {
|
||||
this.db = db;
|
||||
this.name = name;
|
||||
this.staged = new Map(db.stores.get(name));
|
||||
this.pending = 0;
|
||||
this.finished = false;
|
||||
this.error = null;
|
||||
setTimeout(() => this.maybeCommit(), 0);
|
||||
}
|
||||
objectStore(name) {
|
||||
const s = new Store(this, this.staged);
|
||||
s.keyPath = this.db.keyPaths.get(name);
|
||||
return s;
|
||||
}
|
||||
maybeCommit() {
|
||||
if (this.finished || this.pending > 0) return;
|
||||
this.finished = true;
|
||||
this.db.stores.set(this.name, this.staged);
|
||||
setTimeout(() => this.oncomplete?.(), 0);
|
||||
}
|
||||
fail(error) {
|
||||
this.error = error;
|
||||
this.abort();
|
||||
}
|
||||
abort() {
|
||||
if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError");
|
||||
this.finished = true;
|
||||
setTimeout(() => this.onabort?.(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.stores = new Map();
|
||||
this.keyPaths = new Map();
|
||||
}
|
||||
createObjectStore(name, { keyPath }) {
|
||||
this.stores.set(name, new Map());
|
||||
this.keyPaths.set(name, keyPath);
|
||||
}
|
||||
transaction(name) {
|
||||
if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError");
|
||||
return new Transaction(this, name);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.indexedDB = {
|
||||
open(name) {
|
||||
const r = new Request(null);
|
||||
setTimeout(() => {
|
||||
let db = databases.get(name);
|
||||
if (!db) {
|
||||
db = new Database();
|
||||
databases.set(name, db);
|
||||
r.result = db;
|
||||
r.onupgradeneeded?.();
|
||||
}
|
||||
r.result = db;
|
||||
r.onsuccess?.({ target: r });
|
||||
}, 0);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
assert.equal(await host.storeOpen("indexeddb"), "indexeddb");
|
||||
const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key);
|
||||
const bytes = (s) => new TextEncoder().encode(s);
|
||||
const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data));
|
||||
|
||||
// A healthy write resolves once committed, and the store has it then.
|
||||
await host.storePut("project.json", bytes("v1"), 1);
|
||||
assert.equal(text(stored("project.json")), "v1", "a resolved put is committed");
|
||||
|
||||
// The put's request succeeds, then the transaction aborts: the put fails, the old value stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back");
|
||||
|
||||
// The same for a delete: it fails and the entry stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back");
|
||||
|
||||
// A failed request aborts its transaction with the request's error.
|
||||
fault = "requestError";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// A request that throws at once (a value that can't be stored) fails the write.
|
||||
fault = "throw";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// The retry after a failure commits the latest value, and a later delete removes it.
|
||||
await host.storePut("project.json", bytes("v5"), 5);
|
||||
assert.equal(text(stored("project.json")), "v5");
|
||||
await host.storeDelete("project.json");
|
||||
assert.equal(stored("project.json"), undefined, "a resolved delete is committed");
|
||||
const all = await host.storeLoadAll();
|
||||
assert.deepEqual(all, [], "nothing else was written");
|
||||
|
||||
await tick();
|
||||
console.log("storage: ok");
|
||||
@@ -7,6 +7,8 @@
|
||||
// (#218), and not when it is unchanged.
|
||||
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
|
||||
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
|
||||
// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a
|
||||
// frame worker is reported dead, instead of the exception escaping into startup.
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
@@ -67,6 +69,35 @@ assert.equal(failed.kind, "error");
|
||||
assert.ok(workers[1].terminated, "the failed worker is terminated");
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
|
||||
// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame
|
||||
// worker is reported dead, instead of the exception escaping into startup.
|
||||
failStart = false;
|
||||
const RealWorker = globalThis.Worker;
|
||||
globalThis.Worker = class {
|
||||
constructor() {
|
||||
throw new Error("SecurityError: cannot be accessed from origin 'null'");
|
||||
}
|
||||
};
|
||||
// (Take the idle workers out of play first, so that a new one has to be built.)
|
||||
for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) {
|
||||
host.workerRun(id, "{}", [], "http://localhost/", () => {});
|
||||
host.workerCancel(id);
|
||||
}
|
||||
assert.equal(host.workerStats().idle, 0);
|
||||
const threw = await run(6, []);
|
||||
assert.equal(threw.kind, "error");
|
||||
assert.match(threw.json, /SecurityError/);
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
let frameCall = null;
|
||||
const frameDone = new Promise((resolve) => {
|
||||
frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false);
|
||||
});
|
||||
const dead = await frameDone;
|
||||
assert.equal(dead.index, frameCall, "the dead worker keeps its index");
|
||||
assert.match(dead.error, /SecurityError/);
|
||||
host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored
|
||||
globalThis.Worker = RealWorker;
|
||||
|
||||
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
|
||||
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
|
||||
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
|
||||
|
||||
@@ -71,7 +71,13 @@
|
||||
...ec.info(),
|
||||
crossOriginIsolated: self.crossOriginIsolated,
|
||||
webgpu: !!navigator.gpu,
|
||||
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
|
||||
serviceWorker: (() => {
|
||||
try {
|
||||
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
load: window.effectcraftLoad,
|
||||
}),
|
||||
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
|
||||
@@ -83,8 +89,14 @@
|
||||
workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
if (!new URLSearchParams(location.search).has("nosw")) {
|
||||
try {
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("service worker:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Compile once: the page and its render workers instantiate the same module.
|
||||
|
||||
@@ -23,8 +23,8 @@ mod control_server;
|
||||
mod launch_guard;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
// Built everywhere so it stays checked; the macOS and Linux apps use it.
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
mod window;
|
||||
|
||||
@@ -122,6 +122,22 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands' file dialogs open without blocking the frame where the host's backend would:
|
||||
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
|
||||
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
|
||||
/// answer without pumping events, so the window stops answering the compositor and the desktop
|
||||
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
|
||||
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
|
||||
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
let _ = (app, ctx);
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
@@ -301,11 +317,11 @@ fn main() -> eframe::Result {
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
// The commands' file dialogs don't block the frame where the platform's backend
|
||||
// would (#511, #1275); see [`install_file_dialogs`].
|
||||
install_file_dialogs(&mut app, &cc.egui_ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
app.hooks.app_action = Some(Box::new(native_menu::app_action));
|
||||
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
|
||||
}
|
||||
@@ -597,4 +613,17 @@ mod tests {
|
||||
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
|
||||
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
|
||||
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
|
||||
#[test]
|
||||
fn commands_file_dialogs_do_not_block_the_frame() {
|
||||
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
super::install_file_dialogs(&mut app, &egui::Context::default());
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
|
||||
//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`.
|
||||
//!
|
||||
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
|
||||
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
|
||||
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
|
||||
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
|
||||
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
|
||||
//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so
|
||||
//! the main thread sits waiting on the portal's answer and never pumps winit's events — the
|
||||
//! window stops answering the compositor and the desktop offers to force-quit the app while the
|
||||
//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window
|
||||
//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer
|
||||
//! arrives when the dialog closes, outside the frame, and the app runs the command then.
|
||||
|
||||
use effectcraft_ui_egui::{DialogDone, FileDialog};
|
||||
use rfd::{AsyncFileDialog, FileHandle};
|
||||
|
||||
@@ -66,7 +66,7 @@ sync_github_name = true
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.nanox333]
|
||||
display_name = "nanox333"
|
||||
display_name = "nnaannoo"
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
@@ -75,6 +75,23 @@ sync_github_name = true
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.moecr7]
|
||||
[people.onlymoein]
|
||||
real_name = "Moein Saharkhiz"
|
||||
display_name = "onlymoein"
|
||||
|
||||
[people.arnav-hiwarkar]
|
||||
display_name = "arnav-hiwarkar"
|
||||
sync_github_name = false
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`).
|
||||
//! One persistent loopback connection, reconnected once if the app went away.
|
||||
//! One persistent loopback connection. Only explicitly read-only queries can be resent.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
@@ -50,16 +50,20 @@ impl BridgeClient {
|
||||
|
||||
/// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`].
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||
let retry_read =
|
||||
matches!(method, "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.viewer.locate" | "ui.viewer.hit" | "ui.timeline.locate")
|
||||
|| (method == "ui.playback" && params["action"] == "status");
|
||||
let unknown = |error| Error::Bridge(format!("`{method}` request outcome is unknown: {error}; inspect the app before retrying"));
|
||||
let mut last = Error::Bridge("not attempted".into());
|
||||
for attempt in 0..2 {
|
||||
if self.conn.is_none() {
|
||||
self.conn = Some(self.connect()?);
|
||||
}
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.next_id = self.next_id.checked_add(1).ok_or_else(|| Error::Bridge("control request id space exhausted".into()))?;
|
||||
let Some((r, w)) = self.conn.as_mut() else { continue };
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
// `Ok(Err(_))`: the connection is dead before a reply (safe to resend once);
|
||||
// `Ok(Err(_))`: transmission/reply failed (the app may have applied the request);
|
||||
// `Err(_)`: a timeout or bad reply (never resent: the app may have run the request).
|
||||
let res = (|| -> Result<std::result::Result<Value, Error>> {
|
||||
if let Err(e) = w.write_all(line.as_bytes()) {
|
||||
@@ -80,8 +84,11 @@ impl BridgeClient {
|
||||
Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)),
|
||||
Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())),
|
||||
Ok(Err(e)) => {
|
||||
// Stale connection (app restarted): reconnect and resend once.
|
||||
self.conn = None;
|
||||
if !retry_read {
|
||||
return Err(unknown(e));
|
||||
}
|
||||
// A read-only query can be repeated after a stale connection or lost reply.
|
||||
last = e;
|
||||
if attempt == 1 {
|
||||
break;
|
||||
@@ -90,7 +97,7 @@ impl BridgeClient {
|
||||
Err(e) => {
|
||||
// The stream may still deliver the late reply: start fresh next call.
|
||||
self.conn = None;
|
||||
return Err(e);
|
||||
return Err(if retry_read { e } else { unknown(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +511,128 @@ fn bridge_rejects_non_loopback() {
|
||||
assert!(Backend::bridge("localhost:9877").is_ok());
|
||||
}
|
||||
|
||||
/// The app applies the first request but loses its reply. The next connection can be either
|
||||
/// an automatic retry or an explicit follow-up; the returned counts distinguish them.
|
||||
fn lost_reply_server(first_reply: Option<&'static str>) -> (String, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut session = Session::default();
|
||||
let mut mutations = 0;
|
||||
for attempt in 0..2 {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut output = stream.try_clone().unwrap();
|
||||
let mut input = String::new();
|
||||
BufReader::new(stream).read_line(&mut input).unwrap();
|
||||
let request: Value = serde_json::from_str(&input).unwrap();
|
||||
let result = if request["method"] == "engine.execute" {
|
||||
mutations += 1;
|
||||
session.execute(request["params"]["command"].as_str().unwrap(), request["params"]["params"].clone()).unwrap()
|
||||
} else if request["method"] == "ui.inspect" || (request["method"] == "ui.playback" && request["params"]["action"] == "status") {
|
||||
json!({"mutations": mutations, "requests": attempt + 1, "comps": session.project.comps().count()})
|
||||
} else {
|
||||
mutations += 1;
|
||||
json!({"mutations": mutations})
|
||||
};
|
||||
if attempt == 1 {
|
||||
writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
||||
} else if let Some(reply) = first_reply {
|
||||
writeln!(output, "{reply}").unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, worker)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_does_not_replay_a_mutation_when_its_reply_is_lost() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
let state = backend.control("ui.inspect", json!({})).unwrap();
|
||||
assert_eq!(state["mutations"], 1, "the first command was applied exactly once");
|
||||
assert_eq!(state["comps"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_retries_read_only_queries_after_a_lost_reply() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let state = backend.control("ui.inspect", json!({})).unwrap();
|
||||
assert_eq!(state["requests"], 2);
|
||||
assert_eq!(state["mutations"], 0);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_reports_unknown_outcome_after_a_malformed_mutation_reply() {
|
||||
let (addr, worker) = lost_reply_server(Some("{invalid reply"));
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("outcome is unknown") && message.contains("bad reply"), "{message}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["comps"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_does_not_assume_an_unknown_method_is_read_only() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.control("future.customOperation", json!({})).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_playback_edits_and_default_toggle_are_not_replayed() {
|
||||
for params in [json!({}), json!({"action": "toggle"}), json!({"action": "play"}), json!({"action": "stop"})] {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.control("ui.playback", params).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_playback_status_can_be_retried() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let state = backend.control("ui.playback", json!({"action": "status"})).unwrap();
|
||||
assert_eq!(state["requests"], 2);
|
||||
assert_eq!(state["mutations"], 0);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_connect_failure_can_be_retried_when_the_app_starts() {
|
||||
let reserved = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = reserved.local_addr().unwrap();
|
||||
drop(reserved);
|
||||
let mut backend = Backend::bridge(&addr.to_string()).unwrap();
|
||||
let error = backend.control("ui.inspect", json!({})).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot connect"), "{error}");
|
||||
assert!(!error.to_string().contains("outcome is unknown"), "nothing was sent");
|
||||
let listener = TcpListener::bind(addr).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut output = stream.try_clone().unwrap();
|
||||
let mut input = String::new();
|
||||
BufReader::new(stream).read_line(&mut input).unwrap();
|
||||
let request: Value = serde_json::from_str(&input).unwrap();
|
||||
writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": {"ready": true}})).unwrap();
|
||||
});
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["ready"], true);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_ui_and_history_tools() {
|
||||
let mut sess = Session::default();
|
||||
|
||||
@@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -199,6 +202,14 @@ mod tests {
|
||||
assert!(ok("ec.color.lumetri", &[]));
|
||||
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
|
||||
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
|
||||
// #1195: CC Particle World's motion trails (shutter samples) render on the CPU.
|
||||
{
|
||||
let params = ctx_with("ec.sim.ccparticleworld", &[]);
|
||||
let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() };
|
||||
let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env };
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
|
||||
@@ -301,7 +301,8 @@ impl OffsetCurve {
|
||||
}
|
||||
let n = ext.len();
|
||||
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
|
||||
// segments so the curve never overshoots its points.
|
||||
// segments, and at most three times either slope (Fritsch-Carlson) so the curve never
|
||||
// overshoots its points, even beside a short steep segment.
|
||||
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
|
||||
let m: Vec<f32> = (0..n)
|
||||
.map(|k| {
|
||||
@@ -309,7 +310,12 @@ impl OffsetCurve {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (slope(k - 1), slope(k));
|
||||
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
|
||||
if a * b <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let lim = 3.0 * a.abs().min(b.abs());
|
||||
((a + b) * 0.5).clamp(-lim, lim)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let eval = |x: f32| -> f32 {
|
||||
@@ -989,6 +995,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_curve_stays_between_its_points() {
|
||||
// #1656: a point dragged to the bottom right next to another one. The steep short
|
||||
// segment used to pull the long one beside it far outside the box (|offset| > 0.5).
|
||||
// The third pair sits across the hue wrap (0.98 and 0.005 are neighbours).
|
||||
let cases = [("0.3,0.4 0.31,0 0.7,0.6", true), ("0.1,0.3 0.3,0.5 0.31,1 0.7,0.5", false), ("0.5,0.6 0.98,0.4 0.005,0", true)];
|
||||
for (pts, periodic) in cases {
|
||||
let c = OffsetCurve::parse(pts, periodic).unwrap();
|
||||
for i in 0..=1000 {
|
||||
let x = i as f32 / 1000.0;
|
||||
let v = c.at(x);
|
||||
assert!((-0.5..=0.5).contains(&v), "{pts} at {x}: {v}");
|
||||
}
|
||||
for p in parse_points(pts) {
|
||||
assert!((c.at(p.0) - (p.1 - 0.5)).abs() < 0.02, "{pts}: passes through {p:?}");
|
||||
}
|
||||
if periodic {
|
||||
assert!((c.at(0.0) - c.at(1.0)).abs() < 1e-4, "{pts}: no seam at the wrap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
@@ -326,20 +326,27 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
v.abs().powf(p).copysign(v)
|
||||
}
|
||||
|
||||
/// Black & White: a pixel's gray is its smallest channel plus its chroma (largest − smallest
|
||||
/// channel) times the colour sliders' weight for its hue. A neutral pixel keeps its value, a pure
|
||||
/// hue becomes its slider's percentage (Reds 40 % turns pure red into 40 % gray), and the gray
|
||||
/// never moves further from the smallest channel than the pixel's own chroma times the weight.
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
let tint_on = ctx.params.b("tint");
|
||||
let tc = ctx.params.color("tintColor");
|
||||
b.img.map_straight(|c| {
|
||||
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h, _, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
// Interpolate the six hue weights around the colour wheel.
|
||||
let hp = h * 6.0;
|
||||
let i = hp.floor() as usize % 6;
|
||||
let f = hp - hp.floor();
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let base = luminance(c[0], c[1], c[2]);
|
||||
let gray = (base + (wt - 0.5) * s * 0.6).max(0.0);
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
@@ -1220,6 +1227,66 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
let gray = |v: f32| [v, v, v, 1.0];
|
||||
// The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
|
||||
let hues = [
|
||||
([1.0, 0.0, 0.0], 0.4),
|
||||
([1.0, 1.0, 0.0], 0.6),
|
||||
([0.0, 1.0, 0.0], 0.4),
|
||||
([0.0, 1.0, 1.0], 0.6),
|
||||
([0.0, 0.0, 1.0], 0.2),
|
||||
([1.0, 0.0, 1.0], 0.8),
|
||||
// Half as bright, half the gray; orange is halfway from Reds to Yellows.
|
||||
([0.5, 0.0, 0.0], 0.2),
|
||||
([1.0, 0.5, 0.0], 0.5),
|
||||
// Neutral pixels keep their value.
|
||||
([0.3, 0.3, 0.3], 0.3),
|
||||
([4.0, 4.0, 4.0], 4.0),
|
||||
// An overbright red: its smallest channel plus its chroma at Reds' 40 %.
|
||||
([1.5, 0.5, 0.5], 0.9),
|
||||
];
|
||||
for (c, want) in hues {
|
||||
let o = bw(&[], c);
|
||||
assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
|
||||
}
|
||||
assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
|
||||
assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
|
||||
assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
|
||||
}
|
||||
|
||||
/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
|
||||
/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
|
||||
/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
|
||||
/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
|
||||
#[test]
|
||||
fn black_white_keeps_faint_colour_noise_faint() {
|
||||
// A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
|
||||
for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// A dark ramp up to a quarter gray, in whole steps...
|
||||
let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
|
||||
// ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
|
||||
let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
|
||||
clean.set(x, y, [v, v, v, 1.0]);
|
||||
noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
|
||||
}
|
||||
}
|
||||
let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
|
||||
let (a, b) = (bw(clean), bw(noisy));
|
||||
let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
|
||||
assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
|
||||
@@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let kind = ctx.params.e("waveType");
|
||||
let pin = ctx.params.e("pinning");
|
||||
let seed = ctx.seed ^ 0x3a7e;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(h.abs().ceil() as u32 + 1);
|
||||
}
|
||||
@@ -556,8 +559,21 @@ mod tests {
|
||||
let (a, b) = (o.img.get(pad, y + pad), im.get(0, y));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}");
|
||||
}
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im);
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone());
|
||||
assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b)));
|
||||
// #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row,
|
||||
// Bottom Edge (6) the last, and the rest of the layer still moves.
|
||||
for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] {
|
||||
let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)];
|
||||
let o = run("ec.distort.wavewarp", &set, im.clone());
|
||||
let pad = ((o.img.width - im.width) / 2) as i64;
|
||||
for x in 0..40 {
|
||||
let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
let mid = 15;
|
||||
assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -589,7 +589,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
|
||||
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point),
|
||||
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
|
||||
],
|
||||
@@ -672,6 +672,35 @@ mod tests {
|
||||
assert_eq!(zero.img.data, img.data);
|
||||
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
// #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸
|
||||
// Offset) and it moves the mesh: the pattern rendered at x renders at x + offset.
|
||||
let spec = crate::find("ec.distort.liquify").unwrap();
|
||||
assert!(
|
||||
spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point),
|
||||
"offset is shown in Effect Controls"
|
||||
);
|
||||
let shifted = run_fx(
|
||||
"ec.distort.liquify",
|
||||
&[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_ne!(shifted.img.data, out.img.data, "the offset changes the render");
|
||||
// The displaced region's centroid moves right by the offset (the mesh is sampled at
|
||||
// layer minus offset), and the row far left of the shifted mesh is back to untouched.
|
||||
let centroid = |o: &Image| -> f32 {
|
||||
let (mut wsum, mut msum) = (0.0f32, 0.0f32);
|
||||
for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() {
|
||||
let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum();
|
||||
wsum += d;
|
||||
msum += d * (i % 64) as f32;
|
||||
}
|
||||
if wsum > 1e-6 { msum / wsum } else { 0.0 }
|
||||
};
|
||||
let (co, cs) = (centroid(&out.img), centroid(&shifted.img));
|
||||
assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}");
|
||||
assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched");
|
||||
// Reconstruction undoes the warp; freeze protects.
|
||||
let mut rec = s.clone();
|
||||
rec.tool = 9;
|
||||
|
||||
@@ -138,7 +138,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c = c.map(|v| v + n);
|
||||
}
|
||||
*px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode);
|
||||
// None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills
|
||||
// a shape or text, not its bounding box.
|
||||
let a = if gen_mode(mode).is_none() { op * px[3] } else { op };
|
||||
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
|
||||
}
|
||||
});
|
||||
b
|
||||
@@ -420,6 +423,25 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = [0.0, 0.5, 1.0][i % 40 / 14];
|
||||
*p = p.map(|c| c * a);
|
||||
}
|
||||
for op in [100.0, 50.0] {
|
||||
let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default());
|
||||
let k = op as f32 / 100.0;
|
||||
for (src, out) in img.data.iter().zip(&o.img.data) {
|
||||
assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]);
|
||||
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha");
|
||||
}
|
||||
assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
|
||||
let set = [("width", num(8.0)), ("height", num(2.0))];
|
||||
|
||||
@@ -363,7 +363,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
|
||||
let d = qx * nx + qy * ny;
|
||||
let ad = d.abs();
|
||||
if ad > half + soft_w {
|
||||
// The wire is the segment A to B, not an endless line.
|
||||
let along = qx * tx + qy * ty;
|
||||
if !(0.0..=len).contains(&along) || ad > half + soft_w {
|
||||
continue;
|
||||
}
|
||||
// Positions just outside the wire on both sides.
|
||||
@@ -580,6 +582,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_removal_stays_between_a_and_b() {
|
||||
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
|
||||
for y in 0..20 {
|
||||
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let out = run_fx(
|
||||
"ec.key.ccsimplewireremoval",
|
||||
&[("pointA", Value::Vec2([10.5, 5.0])), ("pointB", Value::Vec2([10.5, 15.0])), ("removalStyle", Value::Enum(0)), ("thickness", num(3.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "inside the segment");
|
||||
assert_eq!(out.img.get(10, 2), img.get(10, 2), "before A");
|
||||
assert_eq!(out.img.get(10, 18), img.get(10, 18), "after B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_removal_frame_offset_uses_neighbour_frame() {
|
||||
use crate::{EffectHost, LayerPixels};
|
||||
|
||||
@@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
|
||||
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
|
||||
// LUT's LUT, the OCIO files, #514).
|
||||
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
|
||||
// An internal point that became a control (Liquify's Distortion Mesh Offset, #1194).
|
||||
(ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point,
|
||||
// Same kind of value: take the spec's hints (units, ranges).
|
||||
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
|
||||
_ => {}
|
||||
@@ -514,6 +516,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value.
|
||||
#[test]
|
||||
fn a_hidden_point_that_became_a_control_is_shown() {
|
||||
let mut next = 1;
|
||||
let (spec, mut g) = instance("ec.distort.liquify", &mut next);
|
||||
let pr = g.get_mut("distortionMeshOffset").unwrap();
|
||||
pr.ui = ParamUi::Hidden;
|
||||
pr.value = Value::Vec2([20.0, -5.0]);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]));
|
||||
let pr = g.get("distortionMeshOffset").unwrap();
|
||||
assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_moves_params_into_twirl_down_groups() {
|
||||
let spec = find(crate::warp_stab::ID).unwrap();
|
||||
|
||||
@@ -238,9 +238,22 @@ impl SpritePlan {
|
||||
}
|
||||
|
||||
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
|
||||
pub fn finish(&self, mut b: Buf) -> Buf {
|
||||
pub fn finish(&self, b: Buf) -> Buf {
|
||||
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
|
||||
b.img = match self.post {
|
||||
self.finish_with(b, fx)
|
||||
}
|
||||
|
||||
/// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of
|
||||
/// several frames, #1195).
|
||||
pub fn finish_with(&self, b: Buf, fx: Image) -> Buf {
|
||||
self.post.apply(b, fx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]).
|
||||
fn apply(self, mut b: Buf, fx: Image) -> Buf {
|
||||
b.img = match self {
|
||||
Post::Combine(m) => combine(&b.img, &fx, m),
|
||||
Post::Replace => fx,
|
||||
Post::Lerp(t) => {
|
||||
@@ -1069,6 +1082,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
|
||||
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
}
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32));
|
||||
return match post {
|
||||
Some(p) => p.apply(b, acc),
|
||||
None => b,
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
None => b,
|
||||
@@ -1715,6 +1751,31 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// #1195: with the comp's shutter open, the particles smear across it: the average of the
|
||||
/// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the
|
||||
/// alpha energy.
|
||||
#[test]
|
||||
fn particle_world_motion_blur_trails() {
|
||||
let v = pw_vals(21.0);
|
||||
let blank = Image::new(40, 40);
|
||||
let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0);
|
||||
let blurred = crate::run_fx(
|
||||
"ec.sim.ccparticleworld",
|
||||
&v,
|
||||
blank.clone(),
|
||||
2.0,
|
||||
EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() },
|
||||
)
|
||||
.img;
|
||||
assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles");
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count();
|
||||
let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp));
|
||||
let (sa, ba) = (sum(&sharp), sum(&blurred));
|
||||
assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_world_seek_consistent() {
|
||||
let v = pw_vals(11.0);
|
||||
|
||||
@@ -10,6 +10,7 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-docpath = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-geom = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
|
||||
@@ -47,6 +47,11 @@ pub struct AutoSaveState {
|
||||
#[derive(Clone)]
|
||||
pub struct PendingWrite {
|
||||
pub path: PathBuf,
|
||||
pub(crate) slot: u32,
|
||||
pub(crate) revision: u64,
|
||||
/// Originating project identity: the path changes on Save As, the epoch on Open/New.
|
||||
pub(crate) project_path: Option<String>,
|
||||
pub(crate) project_epoch: u64,
|
||||
handle: std::sync::Arc<std::sync::Mutex<Option<std::thread::JoinHandle<std::io::Result<()>>>>>,
|
||||
}
|
||||
|
||||
@@ -57,9 +62,16 @@ impl std::fmt::Debug for PendingWrite {
|
||||
}
|
||||
|
||||
impl PendingWrite {
|
||||
pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result<PendingWrite> {
|
||||
pub fn spawn(
|
||||
path: PathBuf,
|
||||
slot: u32,
|
||||
revision: u64,
|
||||
project_path: Option<String>,
|
||||
project_epoch: u64,
|
||||
work: impl FnOnce() -> std::io::Result<()> + Send + 'static,
|
||||
) -> std::io::Result<PendingWrite> {
|
||||
let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?;
|
||||
Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) })
|
||||
Ok(PendingWrite { path, slot, revision, project_path, project_epoch, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) })
|
||||
}
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true)
|
||||
|
||||
@@ -182,7 +182,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) {
|
||||
Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?,
|
||||
None => {
|
||||
let (created, _, _) = crate::media_browser::file_dates(&footage.path);
|
||||
let (created, _, _) = if footage.missing { (None, None, None) } else { crate::media_browser::file_dates(&footage.path) };
|
||||
Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key
|
||||
//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs.
|
||||
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease};
|
||||
use effectcraft_project::{LayerId, Property, Uid};
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease};
|
||||
use effectcraft_project::{ItemId, LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if clip.is_empty() {
|
||||
return Err(bad("keys.paste", "no keyframes have been copied"));
|
||||
}
|
||||
// A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425).
|
||||
if let [c] = clip.as_slice()
|
||||
&& let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value)
|
||||
&& let Some(target) = path_target(s, p)?
|
||||
{
|
||||
return paste_path(s, p, target, path);
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
@@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path:
|
||||
/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none.
|
||||
pub(super) fn path_target(s: &Session, p: &Value) -> Result<Option<(ItemId, LayerId, Uid)>> {
|
||||
let (cid, targets) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_));
|
||||
let explicit = targets.first().and_then(|lid| {
|
||||
let l = comp.layer(*lid)?;
|
||||
let pr = match p.get("prop").and_then(Value::as_u64) {
|
||||
Some(u) => l.props.find(u),
|
||||
None => l.props.prop(str_p(p, "path")?),
|
||||
}?;
|
||||
Some((*lid, pr))
|
||||
});
|
||||
if p.get("prop").is_some() || p.get("path").is_some() {
|
||||
return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid)));
|
||||
}
|
||||
let selected = s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.filter(|(lid, _)| targets.contains(lid))
|
||||
.find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion));
|
||||
Ok(selected.map(|(lid, uid)| (cid, *lid, *uid)))
|
||||
}
|
||||
|
||||
/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per
|
||||
/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first
|
||||
/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the
|
||||
/// vertices are pasted as they are (layer-space numbers, not converted to the target's space).
|
||||
pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
|
||||
if path.closed && !order.is_empty() {
|
||||
order.push(0);
|
||||
}
|
||||
if order.is_empty() {
|
||||
return Err(bad("keys.paste", "the copied path has no points"));
|
||||
}
|
||||
let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX);
|
||||
let at = |xy: Option<&[f64; 2]>| {
|
||||
let [x, y] = xy.copied().unwrap_or_default();
|
||||
[x, y, 0.0]
|
||||
};
|
||||
let last = order.len() - 1;
|
||||
let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| {
|
||||
let [x, y, z] = at(path.vertices.get(v));
|
||||
let value = match target.props.find(uid).map(|pr| &pr.value) {
|
||||
Some(KValue::Vec2(_)) => KValue::Vec2([x, y]),
|
||||
_ => KValue::Vec3([x, y, z]),
|
||||
};
|
||||
let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX))));
|
||||
(time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last)
|
||||
})
|
||||
.collect();
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
|
||||
let mut sel = vec![];
|
||||
for (time, value, spatial_in, spatial_out, roving) in keys {
|
||||
let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) };
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&time)) {
|
||||
Ok(i) => pr.keys[i] = k,
|
||||
Err(i) => pr.keys.insert(i, k),
|
||||
}
|
||||
sel.push(time);
|
||||
}
|
||||
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
|
||||
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Select every keyframe of the selected properties, or of the given / selected layers.
|
||||
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, mut layers) = layers_p(s, p)?;
|
||||
|
||||
@@ -10,7 +10,7 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
@@ -147,7 +147,8 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
for (path, src) in &props {
|
||||
match l.props.prop_mut(path) {
|
||||
let resolved = resolve_effect_path(l, path);
|
||||
match l.props.prop_mut(&resolved) {
|
||||
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
|
||||
pr.value = src.value.clone();
|
||||
pr.keys = src
|
||||
|
||||
@@ -245,7 +245,8 @@ fn has_footage(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// `File.execute()` from scripts (and agents): open a file with its default application.
|
||||
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
|
||||
/// the frontend asks first (`confirmed: true` skips the question).
|
||||
/// the frontend asks first. `confirmed: true` is the frontend's answer to that question: a
|
||||
/// script can't give it for itself (#1246), so a script's request always asks.
|
||||
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
|
||||
if !s.prefs.scripting.allow_scripts_write_files {
|
||||
@@ -257,7 +258,8 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(bad("file.executeFile", format!("no file {path}")));
|
||||
}
|
||||
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
|
||||
let confirmed = b_p(p, "confirmed").unwrap_or(false) && !s.running_for_script();
|
||||
if s.prefs.scripting.warn_executing_files && !confirmed {
|
||||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||||
return Ok(json!({"needsConfirmation": path}));
|
||||
}
|
||||
|
||||
@@ -115,6 +115,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !items.is_empty() {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
// Text animators or their selectors selected → duplicate them inside their text layers.
|
||||
let items = super::prop_groups::selected_animator_items(s);
|
||||
if !items.is_empty() {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let new = s.edit("Duplicate", None, |proj, st| {
|
||||
@@ -228,9 +233,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
clear_clipboards(s);
|
||||
return s.execute("keys.copy", json!({}));
|
||||
}
|
||||
// Effects selected (Effect Controls / timeline) → copy the effects.
|
||||
@@ -256,12 +259,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After
|
||||
/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and
|
||||
/// an unanimated one its value, for Paste onto the same properties of other layers (#493).
|
||||
/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste
|
||||
/// onto the same properties of other layers (#493). With keyframes selected only those are copied,
|
||||
/// even across several highlighted properties (#1552); a selected Transform group copies them all.
|
||||
/// `None` when no property (or Transform group) is selected.
|
||||
fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
let Some(comp) = s.active_comp() else { return Ok(None) };
|
||||
let mut leaves = vec![];
|
||||
let mut copy_all_keys = s.state.selected_keys.is_empty();
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.props.find(*uid).is_some() {
|
||||
@@ -269,6 +274,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
} else if let Some(g) = l.props.find_group(*uid)
|
||||
&& l.props.match_path_of(*uid).as_deref() == Some("transform")
|
||||
{
|
||||
copy_all_keys = true;
|
||||
g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid)));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +292,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
{
|
||||
values.push((path, pr.value.clone()));
|
||||
}
|
||||
} else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
} else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
@@ -327,7 +333,8 @@ fn paste_properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lt = l.layer_time(t);
|
||||
for (path, v) in &values {
|
||||
if let Some(pr) = l.props.prop_mut(path)
|
||||
let path = super::resolve_effect_path(l, path);
|
||||
if let Some(pr) = l.props.prop_mut(&path)
|
||||
&& let Some(v) = pr.value.coerce_json(&v.to_json())
|
||||
{
|
||||
pr.set_value_at(lt, v);
|
||||
@@ -457,7 +464,8 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
for (path, e) in &pairs {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
let path = super::resolve_effect_path(l, path);
|
||||
if let Some(pr) = l.props.prop_mut(&path) {
|
||||
pr.expr = Some(e.clone());
|
||||
n += 1;
|
||||
}
|
||||
@@ -495,6 +503,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(clip) = s.state.link_clipboard.clone() {
|
||||
return paste_links(s, clip);
|
||||
}
|
||||
// A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425).
|
||||
if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice()
|
||||
&& let Some(target) = super::anim::path_target(s, p)?
|
||||
{
|
||||
let path = path.clone();
|
||||
return super::anim::paste_path(s, p, target, &path);
|
||||
}
|
||||
if !s.state.value_clipboard.is_empty() {
|
||||
return paste_properties(s, p);
|
||||
}
|
||||
@@ -807,8 +822,30 @@ fn original_path(s: &Session) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Edit Original may hand `path` to the system's default app: a regular file (after
|
||||
/// following links) whose name, and the name it links to, have a media extension the footage
|
||||
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
|
||||
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
|
||||
let media = |p: &std::path::Path| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
|
||||
};
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
let real = std::fs::canonicalize(path).map_err(|e| format!("cannot open “{name}”: {e}"))?;
|
||||
if !real.is_file() {
|
||||
return Err(format!("“{name}” is not a file"));
|
||||
}
|
||||
if !media(std::path::Path::new(path)) || !media(&real) {
|
||||
return Err(format!("Edit Original opens media files only; “{name}” is not one"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
|
||||
let stored = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
|
||||
let path = s.stored_path(&stored).map_err(|why| super::bad("edit.editOriginal", crate::doc_path::refusal(&stored, why)))?;
|
||||
let path = path.to_string_lossy();
|
||||
openable_original(&path).map_err(|e| super::bad("edit.editOriginal", e))?;
|
||||
let url = super::layer_menu::file_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
@@ -951,20 +988,67 @@ fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
|
||||
#[test]
|
||||
fn edit_original_opens_an_encoded_file_url() {
|
||||
let clip = tmp("edit-original-url").join("take #1.mov");
|
||||
std::fs::write(&clip, b"").unwrap();
|
||||
let clip = clip.to_string_lossy().into_owned();
|
||||
let url = super::super::layer_menu::file_url(&clip);
|
||||
assert!(url.ends_with("/take%20%231.mov"), "{url}");
|
||||
let mut s = crate::Session::default();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
|
||||
let f = effectcraft_project::Footage { path: clip.clone(), ..Default::default() };
|
||||
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
let r = s.execute("edit.editOriginal", json!({})).unwrap();
|
||||
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
|
||||
assert_eq!(r["url"], url.as_str());
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == url)));
|
||||
}
|
||||
|
||||
/// Only regular media files reach the system opener.
|
||||
#[test]
|
||||
fn edit_original_opens_media_files_only() {
|
||||
let d = tmp("edit-original-kinds");
|
||||
for f in ["clip.mov", "Still.PNG", "run.bat", "app.desktop", "clip.mov.lnk"] {
|
||||
std::fs::write(d.join(f), b"").unwrap();
|
||||
}
|
||||
for f in ["Tool.app", "folder.mov"] {
|
||||
std::fs::create_dir_all(d.join(f)).unwrap();
|
||||
}
|
||||
let ok = |f: &str| super::openable_original(&d.join(f).to_string_lossy()).is_ok();
|
||||
assert!(ok("clip.mov") && ok("Still.PNG"));
|
||||
for f in ["run.bat", "app.desktop", "clip.mov.lnk", "Tool.app", "folder.mov", "missing.mov", ""] {
|
||||
assert!(!ok(f), "{f}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(d.join("app.desktop"), d.join("link.png")).unwrap();
|
||||
assert!(!ok("link.png"), "a media name linking to another kind of file");
|
||||
}
|
||||
// The command refuses without opening anything.
|
||||
let mut s = crate::Session::default();
|
||||
let path = d.join("run.bat").to_string_lossy().into_owned();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path, ..Default::default() };
|
||||
Ok(p.add_item("bat", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
assert!(s.execute("edit.editOriginal", json!({})).is_err());
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +891,22 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
/// Where a template's `media/<item>/<file>` entry is written: `<media_dir>/<item>/<file>`.
|
||||
/// `None` unless `<item>` is all digits and `<file>` is a plain file name (not empty, `.` or
|
||||
/// `..`, no separators, and on Windows no drive, stream or device name), so every entry lands
|
||||
/// in an item folder directly inside `media_dir`.
|
||||
fn template_media_path(media_dir: &std::path::Path, entry: &str) -> Option<std::path::PathBuf> {
|
||||
let (item, file) = entry.strip_prefix("media/")?.split_once('/')?;
|
||||
if item.is_empty() || !item.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
if !super::scripts::is_bare_name(file) || (cfg!(windows) && super::scripts::windows_unsafe_name(file)) {
|
||||
return None;
|
||||
}
|
||||
let out = media_dir.join(item).join(file);
|
||||
(out.parent().and_then(std::path::Path::parent) == Some(media_dir) && out.file_name() == Some(std::ffi::OsStr::new(file))).then_some(out)
|
||||
}
|
||||
|
||||
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.importTemplate";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
@@ -905,8 +921,7 @@ fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let media_dir = dir.join(format!("{stem} Media"));
|
||||
let mut written = std::collections::HashMap::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
let rel = k.trim_start_matches("media/");
|
||||
let out = media_dir.join(rel);
|
||||
let Some(out) = template_media_path(&media_dir, k) else { continue };
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if s.prefs.project.use_template && !tpl.is_empty() {
|
||||
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
|
||||
s.replace_project(Project::from_json(&text)?, None);
|
||||
let mut proj = Project::from_json(&text)?;
|
||||
crate::doc_path::resolve_footage(&mut proj, Some(&tpl));
|
||||
s.replace_project(proj, None);
|
||||
s.stored_paths.add(&s.project, Some(&tpl));
|
||||
s.update_sentinel();
|
||||
return Ok(json!({"template": tpl}));
|
||||
}
|
||||
@@ -45,8 +48,12 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
|
||||
let (mut proj, repaired) =
|
||||
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
resolve_relative_footage(&mut proj, path, s.services.as_ref());
|
||||
// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or
|
||||
// written by hand) is found next to the project file, as After Effects looks for footage
|
||||
// relative to the project (#652).
|
||||
let refused = crate::doc_path::resolve_footage(&mut proj, Some(path));
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
s.stored_paths.add(&s.project, Some(path));
|
||||
s.restore_view(&effectcraft_project::saved_view(&text));
|
||||
s.note_project_path(path);
|
||||
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
|
||||
@@ -56,6 +63,10 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
|
||||
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
|
||||
}
|
||||
if let Some((_, first, why)) = refused.first() {
|
||||
let n = refused.len();
|
||||
s.toast(format!("{n} footage item{} marked missing: {}.", if n == 1 { "" } else { "s" }, crate::doc_path::refusal(first, *why)));
|
||||
}
|
||||
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||
@@ -72,28 +83,6 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
|
||||
}
|
||||
|
||||
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
|
||||
/// by hand) is found next to the project file, as After Effects looks for footage relative to
|
||||
/// the project. It used to be looked up from the working directory: the CLI started in the
|
||||
/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher,
|
||||
/// in another folder) showed and rendered nothing (#652). A path that exists from the working
|
||||
/// directory is kept.
|
||||
fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) {
|
||||
use std::path::Path;
|
||||
let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return };
|
||||
let beside = |f: &str| dir.join(f).to_string_lossy().to_string();
|
||||
for item in p.items.values_mut() {
|
||||
let ItemKind::Footage(f) = &mut item.kind else { continue };
|
||||
if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) {
|
||||
continue;
|
||||
}
|
||||
f.path = beside(&f.path);
|
||||
for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) {
|
||||
*frame = beside(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a
|
||||
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
|
||||
/// wouldn't read back.
|
||||
@@ -391,7 +380,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
Ok(b) if effectcraft_psd::is_psd(&b) => b,
|
||||
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
|
||||
// PDF / Illustrator / EPS: one layer per file layer.
|
||||
match import_vector_comp(s, &path, &b, page) {
|
||||
match import_vector_comp(s, &path, &b, page, retain) {
|
||||
Ok((comp, made)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
@@ -415,6 +404,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
}
|
||||
}
|
||||
paths = rest;
|
||||
// Composition imports already made their sources, so they bypass add_footage's
|
||||
// probed-footage refresh. Refresh them before any background probe job starts (#1070).
|
||||
for id in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) {
|
||||
s.forget_decoded(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
|
||||
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
|
||||
@@ -459,6 +455,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
|
||||
// media layer keys frames by path): a fresh import reads it again (#1070).
|
||||
for f in &found {
|
||||
s.forget_decoded(&f.footage);
|
||||
s.stored_paths.allow(&f.footage);
|
||||
}
|
||||
s.edit("Import", None, |proj, st| {
|
||||
let mut selection = made;
|
||||
@@ -540,7 +537,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step).
|
||||
/// Returns (comp, items).
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec<u64>)> {
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec<u64>)> {
|
||||
let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
|
||||
if page > 0 {
|
||||
name = format!("{name} (Page {})", page + 1);
|
||||
@@ -549,7 +546,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
|
||||
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
|
||||
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
|
||||
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?;
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?;
|
||||
st.project_selection = vec![r.0];
|
||||
Ok(r)
|
||||
})?;
|
||||
|
||||
@@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"effects": found}))
|
||||
}
|
||||
"fonts" => {
|
||||
let families = crate::text_families();
|
||||
let mut found = vec![];
|
||||
for (cid, c) in s.project.comps() {
|
||||
for l in &c.layers {
|
||||
if let Some(pr) = l.props.prop("text/sourceText")
|
||||
&& let Some(doc) = pr.value.as_text()
|
||||
&& !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font))
|
||||
&& effectcraft_text::resolve(&doc.font, &doc.style).missing
|
||||
{
|
||||
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
|
||||
}
|
||||
@@ -856,12 +855,15 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
|
||||
// does when you locate one).
|
||||
let moved = match s.project.item(item).map(|it| &it.kind) {
|
||||
Some(ItemKind::Footage(previous)) if previous.missing || !s.services.exists(&previous.path) => {
|
||||
Some(ItemKind::Footage(previous)) if previous.missing || !s.stored_paths.exists(s.services.as_ref(), &previous.path) => {
|
||||
super::relink_moved::moved_with(s, item, &previous.path, &path)
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
let relinked: Vec<u64> = moved.iter().map(|(id, _)| id.0).collect();
|
||||
for nf in std::iter::once(&f).chain(moved.iter().map(|(_, nf)| nf)) {
|
||||
s.stored_paths.allow(nf);
|
||||
}
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
|
||||
it.name = name;
|
||||
@@ -947,7 +949,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
|
||||
_ => return Err(bad("file.revealInFinder", "the item is not a file")),
|
||||
};
|
||||
let url = super::layer_menu::folder_url(&path);
|
||||
let url = super::layer_menu::reveal_url(s, &path).map_err(|e| bad("file.revealInFinder", e))?;
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
||||
@@ -140,9 +140,10 @@ fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel).
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
|
||||
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
|
||||
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect();
|
||||
let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect();
|
||||
let base = if base.trim().is_empty() { "Layer".to_string() } else { base };
|
||||
let mut n = base.clone();
|
||||
let mut k = 2;
|
||||
|
||||
@@ -27,40 +27,24 @@ fn read_project(s: &Session, path: &str) -> Result<(Project, Option<String>)> {
|
||||
Ok((project, effectcraft_project::saved_by(&text)))
|
||||
}
|
||||
|
||||
/// Footage paths relative to the imported project's folder become absolute when the file is
|
||||
/// there; returns the footage paths that are missing.
|
||||
fn resolve_footage(s: &Session, project: &mut Project, dir: &std::path::Path) -> Vec<String> {
|
||||
/// Footage paths stored in the imported project are resolved against its file
|
||||
/// ([`crate::doc_path::resolve_footage`]): relative ones become paths beside it, and items whose
|
||||
/// path is not followed are marked missing. Returns the footage paths that are missing, as stored.
|
||||
fn resolve_footage(s: &Session, project: &mut Project, path: &str) -> Vec<String> {
|
||||
let stored: std::collections::HashMap<ItemId, String> =
|
||||
project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.id, f.path.clone())) } else { None }).collect();
|
||||
let refused = crate::doc_path::resolve_footage(project, Some(path));
|
||||
let services = s.services.clone();
|
||||
let resolve = |raw: &mut String| -> bool {
|
||||
if raw.is_empty() || services.exists(raw) {
|
||||
return true;
|
||||
}
|
||||
if std::path::Path::new(raw.as_str()).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let joined = dir.join(raw.as_str()).to_string_lossy().to_string();
|
||||
if services.exists(&joined) {
|
||||
*raw = joined;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
let mut missing = vec![];
|
||||
for it in project.items.values_mut() {
|
||||
if let ItemKind::Footage(f) = &mut it.kind {
|
||||
if f.sequence.is_empty() {
|
||||
if !resolve(&mut f.path) {
|
||||
missing.push(f.path.clone());
|
||||
}
|
||||
} else {
|
||||
let mut all = true;
|
||||
for frame in &mut f.sequence {
|
||||
all &= resolve(frame);
|
||||
}
|
||||
if !all {
|
||||
missing.push(f.path.clone());
|
||||
}
|
||||
let exists = |p: &String| p.is_empty() || services.exists(p);
|
||||
let mut missing: Vec<String> = refused.iter().map(|(_, p, _)| p.clone()).collect();
|
||||
for it in project.items.values() {
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
if refused.iter().any(|(id, _, _)| *id == it.id) {
|
||||
continue;
|
||||
}
|
||||
let present = if f.sequence.is_empty() { exists(&f.path) } else { f.sequence.iter().all(exists) };
|
||||
if !present {
|
||||
missing.push(stored.get(&it.id).cloned().unwrap_or_else(|| f.path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,8 +63,8 @@ fn import_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut sub, saved_by) = read_project(s, &path)?;
|
||||
crate::upgrade_effects(&mut sub);
|
||||
let file = std::path::Path::new(&path);
|
||||
let dir = file.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let missing = resolve_footage(s, &mut sub, &dir);
|
||||
let missing = resolve_footage(s, &mut sub, &path);
|
||||
s.stored_paths.add(&sub, Some(&path));
|
||||
// The imported project's own render queue stays behind (as for templates).
|
||||
let base = s.project.next_id;
|
||||
if sub.next_id.checked_add(base).is_none() {
|
||||
|
||||
@@ -417,6 +417,8 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
|
||||
@@ -115,7 +115,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && l.source.is_av() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
}
|
||||
@@ -677,6 +677,54 @@ pub(crate) fn folder_url(path: &str) -> String {
|
||||
file_url(&dir)
|
||||
}
|
||||
|
||||
/// Extensions of folders that macOS opens as an app, plug-in or other bundle rather than showing
|
||||
/// their contents.
|
||||
const BUNDLE_EXTENSIONS: &[&str] = &[
|
||||
"app",
|
||||
"appex",
|
||||
"bundle",
|
||||
"component",
|
||||
"dext",
|
||||
"driver",
|
||||
"framework",
|
||||
"kext",
|
||||
"mdimporter",
|
||||
"mpkg",
|
||||
"osax",
|
||||
"pkg",
|
||||
"plugin",
|
||||
"prefpane",
|
||||
"qlgenerator",
|
||||
"saver",
|
||||
"service",
|
||||
"systemextension",
|
||||
"vst",
|
||||
"vst3",
|
||||
"wdgt",
|
||||
"workflow",
|
||||
"xpc",
|
||||
];
|
||||
|
||||
/// Whether the system file manager would open folder `dir` as something other than a folder: a
|
||||
/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension.
|
||||
fn opens_as_bundle(dir: &std::path::Path) -> bool {
|
||||
let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||
ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// The folder of the footage file `stored` (a path stored in the project) as a `file://` URL for
|
||||
/// Reveal in Finder. A path that is not followed ([`crate::doc_path`]), or a folder that is not a
|
||||
/// plain folder after following links, is refused.
|
||||
pub(crate) fn reveal_url(s: &Session, stored: &str) -> std::result::Result<String, String> {
|
||||
let path = s.stored_path(stored).map_err(|why| crate::doc_path::refusal(stored, why))?;
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| format!("{stored} is not in a folder"))?;
|
||||
let real = std::fs::canonicalize(dir).map_err(|e| format!("cannot show the folder of {stored}: {e}"))?;
|
||||
if !real.is_dir() || opens_as_bundle(dir) || opens_as_bundle(&real) {
|
||||
return Err(format!("the folder of {stored} is not a plain folder"));
|
||||
}
|
||||
Ok(file_url(&dir.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
|
||||
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
|
||||
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
|
||||
@@ -700,7 +748,7 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
|
||||
_ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")),
|
||||
};
|
||||
let url = folder_url(&path);
|
||||
let url = reveal_url(s, &path).map_err(|e| bad("layer.revealInFinder", e))?;
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
@@ -892,4 +940,27 @@ mod tests {
|
||||
fn folder_url_names_the_containing_folder() {
|
||||
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
|
||||
}
|
||||
|
||||
/// Reveal in Finder shows plain folders only: a bundle the system would open as an app, or a
|
||||
/// link to one, is refused.
|
||||
#[test]
|
||||
fn reveal_refuses_folders_that_open_as_apps() {
|
||||
let d = std::env::temp_dir().join(format!("ec-reveal-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for f in ["Clips", "Tool.app", "Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}"] {
|
||||
std::fs::create_dir_all(d.join(f)).unwrap();
|
||||
}
|
||||
let s = crate::Session::default();
|
||||
let reveal = |folder: &str| super::reveal_url(&s, &d.join(folder).join("a.mov").to_string_lossy());
|
||||
assert_eq!(reveal("Clips"), Ok(super::file_url(&d.join("Clips").to_string_lossy())));
|
||||
assert!(reveal("Tool.app").is_err());
|
||||
assert!(reveal("Shell.{20D04FE0-3AEA-1069-A2D8-08002B30309D}").is_err());
|
||||
assert!(reveal("Gone").is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(d.join("Tool.app"), d.join("Footage")).unwrap();
|
||||
assert!(reveal("Footage").is_err(), "a link to a bundle");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ fn edit_path_counted<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
|
||||
// A path read from a file may have fewer tangents than vertices; the edits below index
|
||||
// all three arrays by vertex (#1250).
|
||||
sp.fit_tangents();
|
||||
let (r, op) = f(&mut sp)?;
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
@@ -287,6 +290,7 @@ fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool,
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
sp.fit_tangents();
|
||||
f(&mut sp, idx);
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
|
||||
@@ -442,6 +442,24 @@ pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_projec
|
||||
None
|
||||
}
|
||||
|
||||
/// Turn `effects/<match name>[#n]/<property>` (as [`match_path_of`] writes it) into a uid path on
|
||||
/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart.
|
||||
/// Any other path, or an effect that is not on the layer, comes back unchanged.
|
||||
pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String {
|
||||
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
|
||||
let (match_id, nth) = match seg.rsplit_once('#') {
|
||||
Some((m, n)) if !m.is_empty() => match n.parse::<usize>() {
|
||||
Ok(n) if n >= 1 => (m, n),
|
||||
_ => (seg, 1),
|
||||
},
|
||||
_ => (seg, 1),
|
||||
};
|
||||
match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) {
|
||||
Some(g) => format!("effects/@{}/{tail}", g.uid),
|
||||
None => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to
|
||||
/// their properties.
|
||||
pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> {
|
||||
|
||||
@@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"rotoBezier": on}))
|
||||
}
|
||||
|
||||
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
|
||||
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
|
||||
|
||||
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
|
||||
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
|
||||
/// path is the shape that was on screen rather than an approximation of it; the defaults match
|
||||
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
|
||||
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
|
||||
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
|
||||
let path = match g.match_id.as_str() {
|
||||
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(
|
||||
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
f("points", 5.0),
|
||||
v2("position", [0.0; 2]),
|
||||
f("rotation", 0.0),
|
||||
f("innerRadius", 50.0),
|
||||
f("outerRadius", 100.0),
|
||||
f("innerRoundness", 0.0),
|
||||
f("outerRoundness", 0.0),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
// A closed parametric shape is one subpath; anything else would not be this shape.
|
||||
effectcraft_path::from_kurbo(&path).into_iter().next()
|
||||
}
|
||||
|
||||
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
// On a shape layer this replaces a Rectangle, Ellipse or Polystar path with an editable
|
||||
// Path 1 of the same geometry, as After Effects does (#932). The parametric properties are
|
||||
// read at the current time and the path that comes out is static: a size or roundness
|
||||
// animation cannot survive the conversion, in After Effects either.
|
||||
let (cid, items) = selected_shape_items(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<(LayerId, Uid)> = items
|
||||
.into_iter()
|
||||
.filter(|(l, u)| {
|
||||
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| PARAMETRIC.contains(&g.match_id.as_str()))
|
||||
})
|
||||
.collect();
|
||||
if shapes.is_empty() {
|
||||
return edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut made = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
|
||||
let Some(old) = contents.find_group(*uid) else { continue };
|
||||
let Some(path) = parametric_path(old, lt) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
|
||||
let enabled = old.enabled;
|
||||
let mut g = build::shape_path(&mut Ids(&mut next), path);
|
||||
if let Some(pr) = g.prop_mut("direction") {
|
||||
pr.value = KV::Enum(direction);
|
||||
}
|
||||
g.enabled = enabled;
|
||||
let new_uid = g.uid;
|
||||
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
if !made.is_empty() {
|
||||
st.selected_props = made.clone();
|
||||
}
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(json!({"paths": made.len(), "of": n}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mask options
|
||||
|
||||
@@ -44,7 +44,8 @@ fn with_prop<T>(
|
||||
) -> Result<T> {
|
||||
let t = s.time_of(cid);
|
||||
let linear = s.prefs.general.default_spatial_linear;
|
||||
s.edit(label, merge, |proj, _| {
|
||||
s.edit(label, merge, |proj, state| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
|
||||
@@ -59,10 +60,61 @@ fn with_prop<T>(
|
||||
k.spatial_out = [0.0; 3];
|
||||
}
|
||||
}
|
||||
if pr.match_id == "type" {
|
||||
let removed = sync_polystar_type(l, uid, lt, &mut next)?;
|
||||
if !removed.is_empty() {
|
||||
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
|
||||
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
/// A static Polygon has no inner controls; switching to Star creates them in schema order
|
||||
/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at
|
||||
/// every time, including when the current value is Polygon.
|
||||
fn sync_polystar_type(layer: &mut Layer, uid: Uid, time: Tick, next: &mut Uid) -> Result<Vec<Uid>> {
|
||||
if !matches!(layer.source, effectcraft_project::LayerSource::Shape) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Some(group) =
|
||||
layer.props.parent_of_mut(uid).filter(|g| g.match_id == "star" && matches!(g.kind, GroupKind::Indexed) && g.get("type").is_some_and(|p| p.uid == uid))
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(kind) = group.get("type") else { return Ok(vec![]) };
|
||||
let needs_inner = kind.is_animated() || kind.has_expression() || kind.value_at(time) == effectcraft_keyframe::Value::Enum(0);
|
||||
if !needs_inner {
|
||||
let mut removed = vec![];
|
||||
group.children.retain(|node| {
|
||||
if let effectcraft_project::Node::Prop(p) = node
|
||||
&& matches!(p.match_id.as_str(), "innerRadius" | "innerRoundness")
|
||||
{
|
||||
removed.push(p.uid);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
return Ok(removed);
|
||||
}
|
||||
for (inner, name, outer, value, ui) in [
|
||||
("innerRadius", "Inner Radius", "outerRadius", 50.0, effectcraft_project::ParamUi::Pixels),
|
||||
("innerRoundness", "Inner Roundness", "outerRoundness", 0.0, effectcraft_project::ParamUi::Percent),
|
||||
] {
|
||||
if group.get(inner).is_none() {
|
||||
let uid = *next;
|
||||
*next = next.checked_add(1).ok_or_else(|| bad("prop", "property ids exhausted"))?;
|
||||
let prop = Property::new(uid, inner, name, effectcraft_keyframe::Value::Scalar(value)).with_ui(ui);
|
||||
let index =
|
||||
group.children.iter().position(|n| matches!(n, effectcraft_project::Node::Prop(p) if p.match_id == outer)).unwrap_or(group.children.len());
|
||||
group.children.insert(index, effectcraft_project::Node::Prop(prop));
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Explicit command key times default to layer time; readers and the CTI use comp time.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum KeyTimeBase {
|
||||
@@ -241,7 +293,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
}
|
||||
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
|
||||
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
|
||||
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
|
||||
/// which keep the rest of their own value (#1434). With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
@@ -261,6 +315,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
|
||||
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
|
||||
let scalar = p.get("value").is_some_and(Value::is_number);
|
||||
// Typed into some components only: the others stay each layer's own.
|
||||
let dims: Option<Vec<usize>> = match (&delta, p.get("dims").and_then(Value::as_array), &new) {
|
||||
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
|
||||
_ => None,
|
||||
};
|
||||
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
|
||||
if let (Some(dims), Some(n)) = (&dims, &new)
|
||||
&& let Some(mut c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
for &d in dims {
|
||||
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
q["value"] = json!(c);
|
||||
}
|
||||
};
|
||||
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
|
||||
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
|
||||
if let Some(d) = &delta
|
||||
@@ -291,6 +362,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
shifted(s, &mut q, olid, ouid, k);
|
||||
typed(s, &mut q, olid, ouid);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
@@ -967,6 +1039,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let sel = s.state.selected_keys.clone();
|
||||
s.edit("Delete Keyframes", None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut removed = 0usize;
|
||||
for k in &sel {
|
||||
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
|
||||
&& let Some(i) = key_at(&pr.keys, k.time)
|
||||
@@ -975,10 +1048,13 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pr.value = pr.keys[0].value.clone();
|
||||
}
|
||||
pr.keys.remove(i);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
st.selected_keys.clear();
|
||||
Ok(json!(sel.len()))
|
||||
// The keys actually removed: a selected key that is gone (or listed twice) isn't counted,
|
||||
// so a caller can tell that a delete changed nothing.
|
||||
Ok(json!(removed))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1229,7 +1305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -100,6 +100,22 @@ pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Text animators and their selectors selected on the active comp's text layers, top to bottom.
|
||||
/// A selector inside a selected animator isn't listed, since it is copied with it.
|
||||
pub(crate) fn selected_animator_items(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
|
||||
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Text)) {
|
||||
if let Some(animators) = l.props.group("text/animators") {
|
||||
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
|
||||
let mut uids = vec![];
|
||||
collect_selected(animators, &selected, &mut uids, 0);
|
||||
out.extend(uids.into_iter().map(|u| (l.id, u)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
|
||||
if depth > super::shape_stroke::MAX_DEPTH {
|
||||
return;
|
||||
@@ -127,8 +143,9 @@ pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
|
||||
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
|
||||
/// unique in their group ("Rectangle 2"…), and the copies are selected.
|
||||
/// Contents, above its topmost selected item (in that item's group), else on top (paints and path
|
||||
/// operations alone: at the bottom). Names stay unique in their group ("Rectangle 2"…), and the
|
||||
/// copies are selected.
|
||||
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.paste";
|
||||
let clip = s.state.contents_clipboard.clone();
|
||||
@@ -140,6 +157,9 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(c, "select a shape layer to paste shape items into"));
|
||||
}
|
||||
let sel = selected_contents(s);
|
||||
let acts_on_above = clip.iter().all(|g| !matches!(g.match_id.as_str(), "group" | "rect" | "ellipse" | "star" | "path"));
|
||||
// Merge Paths clears the draws above it, so at the bottom it would erase the paints (#1681).
|
||||
let has_merge = clip.iter().any(|g| g.match_id == "merge");
|
||||
let label = match clip.as_slice() {
|
||||
[g] => format!("Paste {}", g.name),
|
||||
_ => "Paste".to_string(),
|
||||
@@ -154,11 +174,19 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = l.props.parent_of(*u)?;
|
||||
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
|
||||
});
|
||||
let unselected = spot.is_none();
|
||||
let (parent, at) = match spot {
|
||||
Some((pu, i)) => (l.props.find_group_mut(pu), i),
|
||||
None => (l.props.sub_mut("contents"), 0),
|
||||
};
|
||||
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
|
||||
// Trim Paths, Wiggle Paths, fills… act on the items above them (as Add places them),
|
||||
// so with nothing selected they go to the bottom: on top they'd affect nothing (#1681).
|
||||
let at = match unselected {
|
||||
true if has_merge => merge_spot(parent),
|
||||
true if acts_on_above => parent.children.len(),
|
||||
_ => at,
|
||||
};
|
||||
for (k, g) in clip.iter().enumerate() {
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
@@ -174,8 +202,21 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
|
||||
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
|
||||
/// Where Merge Paths goes in `contents` when nothing is selected: before the first paint, so the
|
||||
/// paints still draw the merged path (the renderer drops the draws above a Merge Paths), else after
|
||||
/// the last path item.
|
||||
fn merge_spot(contents: &PropGroup) -> usize {
|
||||
let is = |n: &Node, ids: &[&str]| matches!(n, Node::Group(g) if ids.contains(&g.match_id.as_str()));
|
||||
let paints = ["fill", "stroke", "gfill", "gstroke"];
|
||||
match contents.children.iter().position(|n| is(n, &paints)) {
|
||||
Some(i) => i,
|
||||
None => contents.children.iter().rposition(|n| is(n, &["rect", "ellipse", "star", "path"])).map_or(0, |i| i + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items, text animators or selectors selected: each is copied in
|
||||
/// place, above the original and named as After Effects does ("Rectangle 1" → "Rectangle 2"),
|
||||
/// and the copies are selected.
|
||||
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
|
||||
let c = "edit.duplicate";
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
@@ -183,7 +224,7 @@ pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> R
|
||||
let mut next = proj.next_id;
|
||||
let mut out = vec![];
|
||||
for (lid, uid) in items {
|
||||
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
|
||||
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the group is gone"))?;
|
||||
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
|
||||
@@ -81,12 +81,13 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?;
|
||||
let services = s.services.clone();
|
||||
let stored = s.stored_paths.clone();
|
||||
let missing: Vec<(ItemId, Footage)> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|item| match &item.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())),
|
||||
ItemKind::Footage(f) if !f.path.is_empty() && !stored.exists(services.as_ref(), &f.path) => Some((item.id, f.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -149,7 +150,8 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A background search must not overwrite edits made while it was running.
|
||||
for (id, old, new) in &updates {
|
||||
if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old))
|
||||
|| services.exists(&old.path) || !services.exists(&new.path)
|
||||
|| stored.exists(services.as_ref(), &old.path)
|
||||
|| !services.exists(&new.path)
|
||||
|| (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p)))
|
||||
{
|
||||
return Err(bad(COMMAND, "footage changed during search; run the search again"));
|
||||
@@ -157,6 +159,9 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let matched = updates.len();
|
||||
if !dry_run && !updates.is_empty() {
|
||||
for (_, _, new) in &updates {
|
||||
s.stored_paths.allow(new);
|
||||
}
|
||||
s.edit("Relink Missing Footage", None, |project, _| {
|
||||
for (id, _, new) in updates {
|
||||
if let Some(item) = project.item_mut(id) {
|
||||
|
||||
@@ -91,8 +91,8 @@ fn moved_footage(footage: &Footage, f: impl Fn(&str) -> Option<String>) -> Optio
|
||||
Some(nf)
|
||||
}
|
||||
|
||||
fn present(s: &dyn Services, f: &Footage) -> bool {
|
||||
!f.path.is_empty() && s.exists(&f.path)
|
||||
fn present(s: &dyn Services, stored: &crate::doc_path::StoredPaths, f: &Footage) -> bool {
|
||||
stored.exists(s, &f.path)
|
||||
}
|
||||
|
||||
/// Footage items whose file is gone (flagged by the footage check, or not there now).
|
||||
@@ -101,7 +101,7 @@ fn missing_items(s: &Session) -> Vec<(ItemId, Footage)> {
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), f)) => {
|
||||
ItemKind::Footage(f) if f.kind != FootageKind::Data && !f.path.is_empty() && (f.missing || !present(s.services.as_ref(), &s.stored_paths, f)) => {
|
||||
Some((i.id, f.clone()))
|
||||
}
|
||||
_ => None,
|
||||
@@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage
|
||||
})
|
||||
}
|
||||
|
||||
/// The folder a project file is in.
|
||||
/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`.
|
||||
pub(crate) fn project_dir(project_path: &str) -> Option<String> {
|
||||
let p = parts(project_path);
|
||||
if p.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?);
|
||||
if !project_path.starts_with('/') {
|
||||
dir = dir.trim_start_matches(['/', '\\']).to_string();
|
||||
}
|
||||
Some(dir)
|
||||
let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?;
|
||||
let root = if project_path.starts_with("\\\\") {
|
||||
"\\\\"
|
||||
} else if project_path.starts_with('/') {
|
||||
"/"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string())))
|
||||
}
|
||||
|
||||
/// Relink `found` in one undo step and say how many.
|
||||
@@ -145,6 +146,9 @@ fn apply(s: &mut Session, label: &str, found: Vec<(ItemId, Footage)>) -> Result<
|
||||
return Ok(json!({"relinked": [], "stillMissing": missing_items(s).len()}));
|
||||
}
|
||||
let ids: Vec<u64> = found.iter().map(|(id, _)| id.0).collect();
|
||||
for (_, nf) in &found {
|
||||
s.stored_paths.allow(nf);
|
||||
}
|
||||
s.edit(label, None, |proj: &mut Project, _| {
|
||||
for (id, nf) in found {
|
||||
if let Some(it) = proj.item_mut(id) {
|
||||
@@ -174,7 +178,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s
|
||||
if id == item {
|
||||
continue;
|
||||
}
|
||||
let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), nf));
|
||||
let same_place = moved_footage(&f, |q| remap(q, &old_root, &new_root)).filter(|nf| present(services.as_ref(), &s.stored_paths, nf));
|
||||
let same_folder = || {
|
||||
let dir = new_dir.as_deref()?;
|
||||
let dir_parts = parts(dir);
|
||||
@@ -182,7 +186,7 @@ pub(crate) fn moved_with(s: &Session, item: ItemId, old_path: &str, new_path: &s
|
||||
let old_dir: Vec<&str> = parts(&f.path).get(..old_dir_len)?.to_vec();
|
||||
moved_footage(&f, |q| remap(q, &old_dir, dir).or_else(|| Some(join(dir, &[file_name(q)])))).filter(|nf| {
|
||||
// Only frames in that one folder (a name match, not a guess deeper down).
|
||||
present(services.as_ref(), nf) && parts(&nf.path).len() == dir_parts.len() + 1
|
||||
present(services.as_ref(), &s.stored_paths, nf) && parts(&nf.path).len() == dir_parts.len() + 1
|
||||
})
|
||||
};
|
||||
if let Some(nf) = same_place.or_else(same_folder) {
|
||||
@@ -262,6 +266,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y"));
|
||||
assert_eq!(project_dir("p.ecproj"), None);
|
||||
// Windows paths keep their separator, and a UNC share its `\\`.
|
||||
assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job"));
|
||||
assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job"));
|
||||
assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png");
|
||||
// Hostile input doesn't panic.
|
||||
for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] {
|
||||
let _ = moved_root(a, b);
|
||||
@@ -302,11 +310,11 @@ mod tests {
|
||||
let b = add(&mut s, "/gone/proj/shots/b.mov");
|
||||
let m = add(&mut s, "/gone/proj/audio/m.wav");
|
||||
let other = add(&mut s, "/elsewhere/c.mov");
|
||||
let target = new.join("shots/a.mov").to_string_lossy().to_string();
|
||||
let target = new.join("shots").join("a.mov").to_string_lossy().to_string();
|
||||
let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}");
|
||||
assert_eq!(r["stillMissing"], 1);
|
||||
assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, other), "/elsewhere/c.mov");
|
||||
// One undo step brings every path back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
@@ -132,7 +132,7 @@ fn is_script_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
fn is_bare_name(name: &str) -> bool {
|
||||
pub(crate) fn is_bare_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ fn is_bare_name(name: &str) -> bool {
|
||||
/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device
|
||||
/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
|
||||
/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`).
|
||||
fn windows_unsafe_name(name: &str) -> bool {
|
||||
pub(crate) fn windows_unsafe_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
|
||||
let port = |p: &str| {
|
||||
let mut n = stem.strip_prefix(p).unwrap_or_default().chars();
|
||||
|
||||
@@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
@@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query};
|
||||
/// The original text animator presets (`presets/text_animators.json`).
|
||||
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
fn presets() -> &'static [Value] {
|
||||
static PRESETS: OnceLock<Vec<Value>> = OnceLock::new();
|
||||
PRESETS
|
||||
.get_or_init(|| serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// (id, name) of every preset.
|
||||
@@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
||||
let preset = presets()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
||||
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
||||
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
||||
|
||||
+32
-14
@@ -164,32 +164,50 @@ impl ConfigStore for DirConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The temporary file an atomic write of `path` goes through.
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
/// A new, empty temporary file next to `path` for an atomic write of it, and its path. The name
|
||||
/// is unique to this process and this write (`.effectcraft-<pid>-<n>.tmp`) and the file is created
|
||||
/// exclusively, so two writers never share one and nothing already at that name (a file, a
|
||||
/// symbolic link) is opened or followed (#1233). It ends in `.tmp`, so a file left by a hard
|
||||
/// kill never counts as an auto-save. Its bounded name does not include the destination's name,
|
||||
/// so a valid long destination name still fits the filesystem's component limit.
|
||||
fn create_temp(path: &Path) -> std::io::Result<(std::fs::File, PathBuf)> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let mut last = None;
|
||||
for _ in 0..16 {
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = path.with_file_name(format!(".effectcraft-{}-{n}.tmp", std::process::id()));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((f, tmp)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| std::io::Error::other("no free temporary file name")))
|
||||
}
|
||||
|
||||
/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then
|
||||
/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a
|
||||
/// torn file.
|
||||
/// torn file, and concurrent writers each publish a whole file of their own.
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
atomic_write_with(path, data, |tmp, data| {
|
||||
atomic_write_with(path, data, |f, data| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A
|
||||
/// failed write removes the temporary file and leaves `path` untouched.
|
||||
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
let tmp = temp_path(path);
|
||||
if let Err(e) = write(&tmp, data) {
|
||||
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write); it is
|
||||
/// given the temporary file, already created. A failed write or rename removes the temporary
|
||||
/// file and leaves `path` untouched.
|
||||
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
let (mut f, tmp) = create_temp(path)?;
|
||||
let written = write(&mut f, data);
|
||||
// Closed before the rename (Windows can't rename an open file).
|
||||
drop(f);
|
||||
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Footage paths stored in a document (the project file, an imported project, the New Project
|
||||
//! template) reach the file system only through [`resolve`]: a relative path is taken from the
|
||||
//! document's folder, and only local files, or files on the network share the document itself is
|
||||
//! on, are followed (see `craft_docpath`).
|
||||
//!
|
||||
//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are
|
||||
//! replaced by the resolved path, and an item with a refused path is marked missing, so the media
|
||||
//! layer never reads it. The session then remembers which paths came from which document
|
||||
//! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit
|
||||
//! Original and Reveal in Finder follow a path only through it. Paths the user imports or relinks
|
||||
//! in this session are followed as given.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind, Project};
|
||||
|
||||
/// Why a stored path is not followed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotFollowed {
|
||||
/// The path is not a local file, or is not on the project's own share.
|
||||
Refused(Refused),
|
||||
/// The path is relative and the project has no folder to take it from.
|
||||
NoProjectFolder,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotFollowed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Refused(r) => r.fmt(f),
|
||||
Self::NoProjectFolder => f.write_str("the path is relative and the project has not been saved"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotFollowed {}
|
||||
|
||||
/// The file to open for `stored`, a footage path or `file:` URL stored in the project saved at
|
||||
/// `project` (`None` for a project without a file), or why it is not followed. A relative
|
||||
/// `project` is taken from the working directory, where it was opened from.
|
||||
pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollowed> {
|
||||
let project = project.filter(|p| !p.is_empty()).map(|p| {
|
||||
let p = Path::new(p);
|
||||
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
});
|
||||
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// project does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoProjectFolder);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
|
||||
/// saved at `document`, or why one of them is not followed.
|
||||
fn footage_paths(f: &Footage, document: Option<&str>) -> Result<(String, Vec<String>), (String, NotFollowed)> {
|
||||
let one = |q: &String| -> Result<String, (String, NotFollowed)> {
|
||||
if q.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
resolve(q, document).map(|p| p.to_string_lossy().into_owned()).map_err(|why| (q.clone(), why))
|
||||
};
|
||||
Ok((one(&f.path)?, f.sequence.iter().map(one).collect::<Result<_, _>>()?))
|
||||
}
|
||||
|
||||
fn paths_of(f: &Footage) -> impl Iterator<Item = &String> {
|
||||
std::iter::once(&f.path).chain(&f.sequence).filter(|q| !q.is_empty())
|
||||
}
|
||||
|
||||
/// Footage paths that came from a document, with the path of that document (`None`: one without
|
||||
/// a file). Any other path was chosen in this session and is followed as given.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StoredPaths(Arc<HashMap<String, Option<String>>>);
|
||||
|
||||
impl StoredPaths {
|
||||
/// Record the footage paths of `p`, loaded from the document at `document`. A path already
|
||||
/// recorded keeps the document it came from first.
|
||||
pub(crate) fn add(&mut self, p: &Project, document: Option<&str>) {
|
||||
let map = Arc::make_mut(&mut self.0);
|
||||
for item in p.items.values() {
|
||||
if let ItemKind::Footage(f) = &item.kind {
|
||||
for q in paths_of(f) {
|
||||
map.entry(q.clone()).or_insert_with(|| document.map(str::to_string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user chose the files of `f` in this session (import, relink, Replace Footage): follow
|
||||
/// them as given from now on.
|
||||
pub(crate) fn allow(&mut self, f: &Footage) {
|
||||
if paths_of(f).any(|q| self.0.contains_key(q)) {
|
||||
let map = Arc::make_mut(&mut self.0);
|
||||
for q in paths_of(f) {
|
||||
map.remove(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file to open for footage path `path`.
|
||||
pub fn follow(&self, path: &str) -> Result<PathBuf, NotFollowed> {
|
||||
match self.0.get(path) {
|
||||
Some(document) => resolve(path, document.as_deref()),
|
||||
None => Ok(PathBuf::from(path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the file at footage path `path` exists. A path that is not followed is not looked
|
||||
/// for.
|
||||
pub(crate) fn exists(&self, services: &dyn crate::Services, path: &str) -> bool {
|
||||
!path.is_empty() && self.follow(path).is_ok_and(|p| p.to_str().is_some_and(|p| services.exists(p)))
|
||||
}
|
||||
|
||||
/// Whether every file of `f` is followed, or the first that is not and why.
|
||||
pub(crate) fn check(&self, f: &Footage) -> Result<(), (String, NotFollowed)> {
|
||||
paths_of(f).try_for_each(|q| self.follow(q).map(|_| ()).map_err(|why| (q.clone(), why)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the footage paths stored in `p`, the project saved at `project`: followed paths are
|
||||
/// replaced by the file they resolve to, and an item with a path that is not followed is marked
|
||||
/// missing. Returns those items with the refused path and why.
|
||||
pub(crate) fn resolve_footage(p: &mut Project, project: Option<&str>) -> Vec<(ItemId, String, NotFollowed)> {
|
||||
let mut refused = vec![];
|
||||
for item in p.items.values_mut() {
|
||||
let ItemKind::Footage(f) = &mut item.kind else { continue };
|
||||
match footage_paths(f, project) {
|
||||
Ok((path, sequence)) => {
|
||||
f.path = path;
|
||||
f.sequence = sequence;
|
||||
}
|
||||
Err((path, why)) => {
|
||||
f.missing = true;
|
||||
refused.push((item.id, path, why));
|
||||
}
|
||||
}
|
||||
}
|
||||
refused
|
||||
}
|
||||
|
||||
/// The message for footage whose stored path is not followed.
|
||||
pub(crate) fn refusal(stored: &str, why: NotFollowed) -> String {
|
||||
format!("the footage file {stored} is not opened: {why}")
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// The file to open for footage path `stored` in this session's project.
|
||||
pub fn stored_path(&self, stored: &str) -> Result<PathBuf, NotFollowed> {
|
||||
self.stored_paths.follow(stored)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Event, Services, Session};
|
||||
|
||||
/// File access that records every path it is asked about.
|
||||
#[derive(Default)]
|
||||
struct Recording(Mutex<Vec<String>>);
|
||||
|
||||
impl Recording {
|
||||
fn note(&self, path: &str) {
|
||||
self.0.lock().unwrap().push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Recording {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.note(path);
|
||||
std::fs::read(path)
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::write(path, data)
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.note(path);
|
||||
std::path::Path::new(path).exists()
|
||||
}
|
||||
fn stamp(&self, path: &str) -> Option<(u64, u128)> {
|
||||
self.note(path);
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some((meta.len(), meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes every file as a still.
|
||||
struct Stills;
|
||||
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
return Err(format!("{path}: no such file"));
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: effectcraft_project::FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn footage_id(s: &Session, name: &str) -> ItemId {
|
||||
s.project.items.values().find(|i| i.name == name).unwrap().id
|
||||
}
|
||||
|
||||
fn footage<'a>(s: &'a Session, name: &str) -> &'a Footage {
|
||||
match &s.project.item(footage_id(s, name)).unwrap().kind {
|
||||
ItemKind::Footage(f) => f,
|
||||
_ => panic!("{name} is not footage"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage paths to other computers stored in a project are never looked at: not on open, by
|
||||
/// the footage check, by the changed-on-disk scan, by Edit Original or by Reveal in Finder. A
|
||||
/// relative path is taken from the project's folder, and a file the user imports in this session
|
||||
/// is used as given until the project is saved and opened again.
|
||||
#[test]
|
||||
fn footage_on_other_computers_is_never_looked_at() {
|
||||
let root = std::env::temp_dir().join(format!("ec-doc-path-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("job");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(root.join("media")).unwrap();
|
||||
std::fs::write(dir.join("clip.png"), b"x").unwrap();
|
||||
std::fs::write(root.join("media").join("far.png"), b"x").unwrap();
|
||||
let mut stored = vec![("unc", r"\\192.0.2.1\share\x.png".to_string()), ("url", "file://192.0.2.1/s/x.png".to_string()), ("beside", "clip.png".to_string())];
|
||||
// Linux reads `//tmp/…/far.png` as the local file: a sink that skipped the check would find it.
|
||||
if cfg!(unix) {
|
||||
stored.push(("slashes", format!("/{}", root.join("media").join("far.png").display())));
|
||||
}
|
||||
let mut s = Session::default();
|
||||
for (name, path) in &stored {
|
||||
let f = Footage { path: path.clone(), width: 64, height: 48, has_video: true, ..Default::default() };
|
||||
Arc::make_mut(&mut s.project).add_item(name, crate::color::Label::None, None, ItemKind::Footage(f));
|
||||
}
|
||||
let project = dir.join("p.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": project.to_string_lossy()})).unwrap();
|
||||
|
||||
let rec = Arc::new(Recording::default());
|
||||
let mut o = Session { services: rec.clone(), ..Default::default() };
|
||||
o.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap();
|
||||
for (name, _) in &stored {
|
||||
assert_eq!(footage(&o, name).missing, *name != "beside", "{name}");
|
||||
}
|
||||
assert_eq!(std::path::Path::new(&footage(&o, "beside").path), dir.join("clip.png"));
|
||||
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"].as_u64(), Some(stored.len() as u64 - 1), "{r}");
|
||||
o.execute("footage.reloadChanged", json!({})).unwrap();
|
||||
o.drain_events();
|
||||
for (name, _) in &stored {
|
||||
o.state.project_selection = vec![footage_id(&o, name)];
|
||||
let opened = o.execute("edit.editOriginal", json!({}));
|
||||
let revealed = o.execute("file.revealInFinder", json!({}));
|
||||
assert_eq!((opened.is_ok(), revealed.is_ok()), (*name == "beside", *name == "beside"), "{name}: {opened:?} {revealed:?}");
|
||||
}
|
||||
let urls: Vec<String> = o.drain_events().into_iter().filter_map(|e| if let Event::OpenUrl(u) = e { Some(u) } else { None }).collect();
|
||||
assert_eq!(urls.len(), 2, "{urls:?}");
|
||||
|
||||
let seen = rec.0.lock().unwrap().clone();
|
||||
assert!(seen.iter().any(|p| std::path::Path::new(p) == dir.join("clip.png")), "{seen:?}");
|
||||
// Only files in the temporary folder (the project's folder, where moved footage is looked for).
|
||||
let local = root.to_string_lossy().into_owned();
|
||||
for p in &seen {
|
||||
assert!(p.starts_with(&local), "looked at {p}");
|
||||
}
|
||||
|
||||
// Chosen in this session, the same file is followed as given (as a file on a NAS would be)…
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let far = stored.iter().find(|(n, _)| *n == "slashes").unwrap().1.clone();
|
||||
o.importer = Some(Arc::new(Stills));
|
||||
o.execute("file.import", json!({"paths": [far.clone()]})).unwrap();
|
||||
let imported = |s: &Session| -> Vec<ItemId> {
|
||||
s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path == far)).map(|i| i.id).collect()
|
||||
};
|
||||
let id = *imported(&o).iter().find(|id| matches!(&o.project.item(**id).unwrap().kind, ItemKind::Footage(f) if !f.missing)).unwrap();
|
||||
o.state.project_selection = vec![id];
|
||||
o.execute("edit.editOriginal", json!({})).unwrap();
|
||||
o.execute("file.revealInFinder", json!({})).unwrap();
|
||||
assert!(o.execute("footage.check", json!({"wait": true, "items": [id.0]})).unwrap()["missing"] == 0);
|
||||
// …and is a stored path again once the project is saved and opened.
|
||||
o.execute("file.save", json!({})).unwrap();
|
||||
let mut q = Session::default();
|
||||
q.execute("file.open", json!({"path": project.to_string_lossy()})).unwrap();
|
||||
let ids = imported(&q);
|
||||
assert_eq!(ids.len(), 2);
|
||||
for id in ids {
|
||||
assert!(matches!(&q.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing));
|
||||
q.state.project_selection = vec![id];
|
||||
assert!(q.execute("edit.editOriginal", json!({})).is_err());
|
||||
assert!(q.execute("file.revealInFinder", json!({})).is_err());
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_paths_resolve_against_the_project() {
|
||||
let doc = std::env::temp_dir().join("art").join("intro.ecproj");
|
||||
let doc = doc.to_str().unwrap();
|
||||
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
|
||||
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
|
||||
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
|
||||
#[cfg(windows)]
|
||||
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
|
||||
}
|
||||
@@ -31,10 +31,6 @@ enum Found {
|
||||
Moved(Box<Footage>),
|
||||
}
|
||||
|
||||
fn exists(s: &dyn crate::Services, path: &str) -> bool {
|
||||
!path.is_empty() && s.exists(path)
|
||||
}
|
||||
|
||||
/// Start the check (all footage, or `items`). `wait` runs it to completion first (always on
|
||||
/// wasm32); the result is then `{checked, missing, probed}`.
|
||||
pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::Result<Value> {
|
||||
@@ -54,6 +50,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let rate = s.sequence_rate();
|
||||
let stored = s.stored_paths.clone();
|
||||
let project_dir = s.path.as_deref().and_then(crate::commands::relink_moved::project_dir);
|
||||
let n = jobs.len();
|
||||
s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| {
|
||||
@@ -64,13 +61,17 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let f = &j.footage;
|
||||
let present = if f.sequence.is_empty() {
|
||||
exists(services.as_ref(), &f.path)
|
||||
} else {
|
||||
// A sequence is there while any of its frames is (missing frames render as
|
||||
// placeholders, as in After Effects).
|
||||
exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p))
|
||||
};
|
||||
// A path that is not followed is never looked for (the item stays missing).
|
||||
let followed = stored.check(f).is_ok();
|
||||
let exists = |p: &str| stored.exists(services.as_ref(), p);
|
||||
let present = followed
|
||||
&& if f.sequence.is_empty() {
|
||||
exists(&f.path)
|
||||
} else {
|
||||
// A sequence is there while any of its frames is (missing frames render as
|
||||
// placeholders, as in After Effects).
|
||||
exists(&f.path) || f.sequence.iter().any(|p| exists(p))
|
||||
};
|
||||
// Moved with the project: the same place under the project's folder.
|
||||
if !present
|
||||
&& let Some(dir) = &project_dir
|
||||
@@ -233,9 +234,11 @@ mod tests {
|
||||
let mut paths: Vec<String> =
|
||||
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
|
||||
paths.sort();
|
||||
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
|
||||
// separators; `Path` equality compares components, so both spellings are the same file.
|
||||
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
// `Path` equality compares components, so `/` and `\` spell the same file on Windows.
|
||||
let abs = crate::doc_path::resolve("/nonexistent/ec/abs.png", path.to_str()).unwrap();
|
||||
let gone = dir.join("media").join("ec-relative-gone.png");
|
||||
let mut expected = [abs.to_str().unwrap(), clip.to_str().unwrap(), gone.to_str().unwrap()];
|
||||
expected.sort();
|
||||
assert_eq!(paths.len(), expected.len(), "{paths:?}");
|
||||
for (got, want) in paths.iter().zip(expected) {
|
||||
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
|
||||
@@ -254,7 +257,7 @@ mod tests {
|
||||
std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap();
|
||||
// Saved on another machine: the footage sat next to the project in /Users/x/Job.
|
||||
let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]);
|
||||
let path = d.join("Job/p.ecproj");
|
||||
let path = d.join("Job").join("p.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut o = Session::default();
|
||||
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
@@ -269,7 +272,7 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(!o.is_dirty(), "finding the files is not an edit");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,13 @@ pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
|
||||
/// The files footage items read: each item's file and every frame of an image sequence. Image
|
||||
/// sequences count only with Automatically Reload Footage set to All Footage (After Effects'
|
||||
/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up).
|
||||
/// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched.
|
||||
fn watched(s: &Session) -> BTreeSet<String> {
|
||||
let sequences = s.prefs.import.auto_reload_footage == "all";
|
||||
let mut paths = BTreeSet::new();
|
||||
for it in s.project.items.values() {
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
if !f.sequence.is_empty() && !sequences {
|
||||
if (!f.sequence.is_empty() && !sequences) || s.stored_paths.check(f).is_err() {
|
||||
continue;
|
||||
}
|
||||
if !f.path.is_empty() {
|
||||
@@ -58,6 +59,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
|
||||
Some((u32::try_from(found.index).ok()?, size))
|
||||
}
|
||||
|
||||
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
|
||||
let bytes = s.services.read_file(&f.path).ok()?;
|
||||
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
|
||||
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
|
||||
}
|
||||
|
||||
/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`).
|
||||
fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
let mut nf = f.clone();
|
||||
@@ -89,6 +96,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
match &f.layer {
|
||||
// A smart object's size is its embedded file's, recorded at import.
|
||||
Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height),
|
||||
Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)),
|
||||
Some(l) => match psd_layer(s, &f.path, l) {
|
||||
Some((index, size)) => {
|
||||
if let Some(nl) = nf.layer.as_mut() {
|
||||
@@ -129,8 +137,10 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi
|
||||
let mut failed = BTreeSet::new();
|
||||
for i in ids {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
// Probed the way it imports by default, so a sequence is found as one.
|
||||
let nf = match s.probe_footage(&f.path) {
|
||||
// Probed the way it imports by default, so a sequence is found as one. A path that
|
||||
// is not followed is not read.
|
||||
let probed = s.stored_paths.check(f).map_err(|(p, why)| crate::doc_path::refusal(&p, why)).and_then(|_| s.probe_footage(&f.path));
|
||||
let nf = match probed {
|
||||
Ok(p) => refreshed(s, f, p),
|
||||
Err(_) if undoable => Footage { missing: true, ..f.clone() },
|
||||
Err(_) => {
|
||||
|
||||
+30
-10
@@ -15,6 +15,7 @@ pub mod camera_track;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod doc_path;
|
||||
pub mod footage_check;
|
||||
pub mod footage_reload;
|
||||
pub mod guard;
|
||||
@@ -344,6 +345,11 @@ fn one_view() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// `t` as a current time of `comp`: a frame within its duration.
|
||||
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
|
||||
comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO))
|
||||
}
|
||||
|
||||
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkClip {
|
||||
@@ -495,6 +501,8 @@ pub struct Session {
|
||||
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
|
||||
/// (`wait: true` over the control channel in the browser) learns its outcome.
|
||||
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
|
||||
/// Number of older completion entries evicted from the bounded offload log.
|
||||
pub offload_log_start: usize,
|
||||
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
|
||||
pub tasks: Vec<jobs::Task>,
|
||||
/// Finished tasks, newest last (Progress panel, `jobs.list`).
|
||||
@@ -506,6 +514,8 @@ pub struct Session {
|
||||
/// event loop (the desktop app) turn this on; headless sessions run `footage.check` when
|
||||
/// they want it.
|
||||
pub check_footage_on_open: bool,
|
||||
/// Footage paths that came from a document, with that document ([`doc_path`]).
|
||||
pub stored_paths: doc_path::StoredPaths,
|
||||
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
|
||||
/// Reload Footage Changed on Disk, [`footage_reload`]).
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
@@ -598,6 +608,7 @@ impl Default for Session {
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
offload_log: vec![],
|
||||
offload_log_start: 0,
|
||||
browser: None,
|
||||
storage: None,
|
||||
tasks: vec![],
|
||||
@@ -605,6 +616,7 @@ impl Default for Session {
|
||||
next_task_id: 1,
|
||||
learn: None,
|
||||
check_footage_on_open: false,
|
||||
stored_paths: Default::default(),
|
||||
footage_stamps: Default::default(),
|
||||
footage_scan: None,
|
||||
script_calls: ScriptCalls::default(),
|
||||
@@ -857,11 +869,7 @@ impl Session {
|
||||
|
||||
pub fn set_time(&mut self, t: Tick) {
|
||||
if let Some(c) = self.state.active_comp {
|
||||
let comp = self.project.comp(c);
|
||||
let t = match comp {
|
||||
Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())),
|
||||
None => t,
|
||||
};
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
self.sync_related_times(c, t);
|
||||
@@ -980,15 +988,24 @@ impl Session {
|
||||
self.events.push(Event::OpenComp(id));
|
||||
}
|
||||
|
||||
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
|
||||
/// The open compositions and their current times, as a project file records them
|
||||
/// ([`effectcraft_project::SavedView`]).
|
||||
pub fn saved_view(&self) -> effectcraft_project::SavedView {
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
|
||||
let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect();
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times }
|
||||
}
|
||||
|
||||
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
|
||||
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
|
||||
/// opened (its first), as files from before the view was saved do.
|
||||
/// Reopen the compositions a project file was saved with (#748), each at its saved current
|
||||
/// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays
|
||||
/// on the comp [`Session::replace_project`] opened (its first), as files from before the view
|
||||
/// was saved do.
|
||||
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
|
||||
for (id, t) in &view.comp_times {
|
||||
if let Some(comp) = self.project.comp(*id) {
|
||||
let t = comp_frame_at(comp, *t);
|
||||
self.state.times.insert(*id, t);
|
||||
}
|
||||
}
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
|
||||
if !open.contains(&id) {
|
||||
@@ -1125,6 +1142,7 @@ impl Session {
|
||||
job.wait();
|
||||
}
|
||||
self.render_job = None;
|
||||
self.stored_paths = Default::default();
|
||||
self.stop_track();
|
||||
self.track_job = None;
|
||||
self.stop_mask_track();
|
||||
@@ -1224,6 +1242,8 @@ mod tests_codec_options;
|
||||
#[cfg(test)]
|
||||
mod tests_color_view;
|
||||
#[cfg(test)]
|
||||
mod tests_delete_keys;
|
||||
#[cfg(test)]
|
||||
mod tests_disk_cache;
|
||||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
|
||||
@@ -173,7 +173,7 @@ impl VirtualTree {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,10 @@ pub struct Entry {
|
||||
pub kind: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn sort_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||
}
|
||||
|
||||
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
@@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -302,7 +306,7 @@ fn kind_name(k: FootageKind) -> &'static str {
|
||||
|
||||
/// Metadata of footage (the fields the Metadata panel lists).
|
||||
pub fn footage_metadata(f: &Footage) -> Value {
|
||||
let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path));
|
||||
let (created, modified, size) = if f.missing { (None, None, None) } else { file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path)) };
|
||||
json!({
|
||||
"path": f.path,
|
||||
"kind": kind_name(f.kind),
|
||||
@@ -312,7 +316,9 @@ pub fn footage_metadata(f: &Footage) -> Value {
|
||||
"pixelAspect": f.pixel_aspect,
|
||||
"frameRate": f.frame_rate.as_f64(),
|
||||
"duration": f.duration.seconds(),
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
|
||||
// Rounded like the media pool's frame count: Matroska stores the duration in whole
|
||||
// milliseconds, a hair short of the last frame at 30 fps (#1429).
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None },
|
||||
"hasVideo": f.has_video,
|
||||
"hasAudio": f.has_audio,
|
||||
"alpha": format!("{:?}", f.alpha),
|
||||
@@ -407,6 +413,10 @@ mod tests {
|
||||
assert_eq!(media.len(), 3);
|
||||
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
|
||||
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
|
||||
std::fs::write(dir.join("Ω.csv"), b"x").unwrap();
|
||||
std::fs::write(dir.join("作品.csv"), b"x").unwrap();
|
||||
let all = list(&d, false).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -436,6 +446,61 @@ mod tests {
|
||||
assert_eq!(vparent("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_keep_folders_first_and_stable_unicode_order() {
|
||||
let mut tree = VirtualTree::default();
|
||||
for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] {
|
||||
tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None);
|
||||
}
|
||||
for name in ["zFolder", "Alpha"] {
|
||||
tree.add_dir(&format!("/Shoot/{name}"));
|
||||
}
|
||||
let entries = tree.list("/Shoot", true).unwrap();
|
||||
assert_eq!(
|
||||
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"]
|
||||
);
|
||||
assert_eq!(entries[2].path, "/files/A.csv");
|
||||
let mut reversed = entries.iter().rev().cloned().collect::<Vec<_>>();
|
||||
sort_entries(&mut reversed);
|
||||
assert_eq!(
|
||||
reversed.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Media Browser filename sorting benchmark"]
|
||||
fn directory_sort_benchmark() {
|
||||
let entries: Vec<_> = (0..1000)
|
||||
.map(|i| Entry {
|
||||
name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000),
|
||||
path: format!("/Shoot/{i}.mov"),
|
||||
is_dir: i % 7 == 0,
|
||||
size: 1,
|
||||
modified: None,
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let mut old = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
before.push(start.elapsed());
|
||||
let mut new = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
sort_entries(&mut new);
|
||||
after.push(start.elapsed());
|
||||
assert_eq!(old, new);
|
||||
std::hint::black_box(new);
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -171,6 +171,20 @@ pub enum JobKind {
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// The corresponding `jobs.list` id, also used by waiting control commands.
|
||||
pub fn job_id(self) -> &'static str {
|
||||
match self {
|
||||
JobKind::Render => "render",
|
||||
JobKind::Warp => "warp",
|
||||
JobKind::WarpPlan => "warpPlan",
|
||||
JobKind::Camera => "camera",
|
||||
JobKind::RotoFreeze | JobKind::RotoPropagate => "roto",
|
||||
JobKind::Track => "track",
|
||||
JobKind::MaskTrack => "maskTrack",
|
||||
JobKind::ContentFill => "contentFill",
|
||||
}
|
||||
}
|
||||
|
||||
/// The undo step an analysis result becomes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -296,13 +310,15 @@ pub fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Footage files a project reads (paths, sequence frames).
|
||||
/// Footage files a project reads (paths, sequence frames; missing items read none).
|
||||
pub fn footage_files(p: &Project) -> Vec<String> {
|
||||
let mut v: Vec<String> = p
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
|
||||
ItemKind::Footage(f) if !f.path.is_empty() && !f.missing => {
|
||||
Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
@@ -699,6 +715,9 @@ pub struct OffloadedJob {
|
||||
/// Segmentations received (Roto Brush propagation).
|
||||
pub segs: u64,
|
||||
started: web_time::Instant,
|
||||
/// The analysed group as the job started: its result is merged with what changed since
|
||||
/// ([`merge_group`]).
|
||||
base: Option<Box<PropGroup>>,
|
||||
}
|
||||
|
||||
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
|
||||
@@ -730,6 +749,7 @@ impl Session {
|
||||
}
|
||||
let id = next_id();
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let base = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().map(Box::new);
|
||||
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
|
||||
off.start(req, inbox.clone())?;
|
||||
self.offloaded.push(OffloadedJob {
|
||||
@@ -742,6 +762,7 @@ impl Session {
|
||||
progress: OffloadProgress::default(),
|
||||
segs: 0,
|
||||
started: web_time::Instant::now(),
|
||||
base,
|
||||
});
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
@@ -779,7 +800,7 @@ impl Session {
|
||||
j.progress.done = done;
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.id, j.kind, comp, layer, group, message, j.base.clone())),
|
||||
WorkerReply::Fill { plan, files } => fills.push((plan, files)),
|
||||
WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary),
|
||||
WorkerReply::Segs { segs } => {
|
||||
@@ -802,7 +823,7 @@ impl Session {
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }),
|
||||
}
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
for (id, kind, comp, layer, group, message, base) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
let g = p
|
||||
@@ -810,12 +831,31 @@ impl Session {
|
||||
.and_then(|c| c.layer_mut(layer))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
|
||||
*g = *group;
|
||||
// Edits made while the job ran stay (#1249): only what the analysis changed is
|
||||
// taken from its result.
|
||||
*g = match base {
|
||||
Some(base) => merge_group(&base, g, &group).ok_or_else(|| {
|
||||
crate::EngineError::Other(format!(
|
||||
"“{}” was changed during the analysis where its result changes it too, so nothing was applied: analyse again",
|
||||
g.name
|
||||
))
|
||||
})?,
|
||||
None => *group,
|
||||
};
|
||||
Ok(())
|
||||
});
|
||||
match r {
|
||||
Ok(()) => self.events.push(Event::Toast { message, error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
|
||||
Err(e) => {
|
||||
// Completion consumers read the job's error, not its toasts. Keep an apply
|
||||
// rejection on the originating job; Done logs and reports it just like a
|
||||
// WorkerReply::Failed, without failing another concurrent analysis.
|
||||
if let Some(job) = self.offloaded.iter_mut().find(|j| j.id == id) {
|
||||
job.progress.error = Some(e.to_string());
|
||||
} else {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut done = vec![];
|
||||
@@ -830,6 +870,7 @@ impl Session {
|
||||
self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string()))));
|
||||
if self.offload_log.len() > 32 {
|
||||
self.offload_log.remove(0);
|
||||
self.offload_log_start = self.offload_log_start.saturating_add(1);
|
||||
}
|
||||
if let Some(e) = p.error {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
@@ -880,6 +921,76 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// `live` if only it changed since `base`, `result` if only that did (or both the same way);
|
||||
/// `None` when both changed it differently.
|
||||
fn pick<T: PartialEq + Clone>(base: &T, live: &T, result: &T) -> Option<T> {
|
||||
if live == base {
|
||||
Some(result.clone())
|
||||
} else if result == base || result == live {
|
||||
Some(live.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn node_uid(n: &effectcraft_project::Node) -> Uid {
|
||||
match n {
|
||||
effectcraft_project::Node::Prop(p) => p.uid,
|
||||
effectcraft_project::Node::Group(g) => g.uid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge an analysis `result` into the `live` group (#1249). `base` is the group as the job
|
||||
/// started. The group's own fields and each property are taken from whichever side changed
|
||||
/// them, groups are merged child by child (matched by uid), and a child added or removed by one
|
||||
/// side is added or removed. `None` when both sides changed the same field or property
|
||||
/// differently, one side removed a child the other changed, or both reordered or added and
|
||||
/// removed children differently: a conflict.
|
||||
pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup) -> Option<PropGroup> {
|
||||
use effectcraft_project::Node;
|
||||
if live == base {
|
||||
return Some(result.clone());
|
||||
}
|
||||
if result == base || result == live {
|
||||
return Some(live.clone());
|
||||
}
|
||||
let header = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() };
|
||||
let mut out = pick(&header(base), &header(live), &header(result))?;
|
||||
let ids = |g: &PropGroup| g.children.iter().map(node_uid).collect::<Vec<_>>();
|
||||
let (bi, li, ri) = (ids(base), ids(live), ids(result));
|
||||
// The children's order and set: from the side that changed them.
|
||||
let order = if li == bi {
|
||||
ri
|
||||
} else if ri == bi || ri == li {
|
||||
li
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
fn find(g: &PropGroup, u: Uid) -> Option<&effectcraft_project::Node> {
|
||||
g.children.iter().find(|n| node_uid(n) == u)
|
||||
}
|
||||
// A child one side removed must be unchanged on the other, or that side's edit would be lost.
|
||||
for u in bi.iter().filter(|u| !order.contains(u)) {
|
||||
let b = find(base, *u);
|
||||
if [find(live, *u), find(result, *u)].into_iter().flatten().any(|n| Some(n) != b) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for u in order {
|
||||
let node = match (find(base, u), find(live, u), find(result, u)) {
|
||||
(Some(Node::Group(b)), Some(Node::Group(l)), Some(Node::Group(r))) => Node::Group(merge_group(b, l, r)?),
|
||||
(Some(b), Some(l), Some(r)) => pick(b, l, r)?,
|
||||
// Added by one side, or by both alike.
|
||||
(None, Some(l), None) => l.clone(),
|
||||
(None, None, Some(r)) => r.clone(),
|
||||
(None, Some(l), Some(r)) if l == r => l.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
out.children.push(node);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -892,6 +1003,69 @@ mod tests {
|
||||
a
|
||||
}
|
||||
|
||||
/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c`
|
||||
/// (uid 5).
|
||||
fn group() -> PropGroup {
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Node, Property};
|
||||
let mut sub = PropGroup::new(4, "sub", "Sub");
|
||||
sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0))));
|
||||
let mut g = PropGroup::new(1, "fx", "Effect");
|
||||
g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0))));
|
||||
g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0))));
|
||||
g.children.push(Node::Group(sub));
|
||||
g
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, uid: Uid, v: f64) {
|
||||
g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v);
|
||||
}
|
||||
|
||||
fn get(g: &PropGroup, uid: Uid) -> f64 {
|
||||
match g.find(uid).unwrap().value {
|
||||
effectcraft_keyframe::Value::Scalar(v) => v,
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_results_merge_with_edits_made_meanwhile() {
|
||||
use effectcraft_project::Node;
|
||||
let base = group();
|
||||
// The user changes `a` and switches the effect off; the analysis writes `b` and `c`.
|
||||
let mut live = base.clone();
|
||||
set(&mut live, 2, 1.0);
|
||||
live.enabled = false;
|
||||
let mut result = base.clone();
|
||||
set(&mut result, 3, 2.0);
|
||||
set(&mut result, 5, 3.0);
|
||||
let m = merge_group(&base, &live, &result).unwrap();
|
||||
assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false));
|
||||
// Unchanged on either side: the other side wins whole.
|
||||
assert_eq!(merge_group(&base, &base, &result).unwrap(), result);
|
||||
assert_eq!(merge_group(&base, &live, &base).unwrap(), live);
|
||||
// Both changed `b`, differently: a conflict (nothing is applied). The same way: fine.
|
||||
let mut both = live.clone();
|
||||
set(&mut both, 3, 9.0);
|
||||
assert!(merge_group(&base, &both, &result).is_none());
|
||||
set(&mut both, 3, 2.0);
|
||||
assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0);
|
||||
// The analysis adds a child (a result group) while the user edits `a`: both kept.
|
||||
let mut added = result.clone();
|
||||
added.children.push(Node::Group(PropGroup::new(6, "out", "Output")));
|
||||
let m = merge_group(&base, &live, &added).unwrap();
|
||||
assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0);
|
||||
// The analysis drops the sub-group the user didn't touch: dropped.
|
||||
let mut dropped = result.clone();
|
||||
dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4));
|
||||
let m = merge_group(&base, &live, &dropped).unwrap();
|
||||
assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0);
|
||||
// …but if the user changed something in it, that's a conflict.
|
||||
let mut touched = live.clone();
|
||||
set(&mut touched, 5, 7.0);
|
||||
assert!(merge_group(&base, &touched, &dropped).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_round_trips() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -428,6 +428,8 @@ pub struct Prefs {
|
||||
pub recent_presets: Vec<String>,
|
||||
/// Character panel: recently used font families, newest first.
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Effects & Presets: the starred effects' ids, in the order starred.
|
||||
pub effects_favorites: Vec<String>,
|
||||
/// Preview panel: every shortcut's options.
|
||||
pub preview: crate::preview::PreviewSettings,
|
||||
/// View ▸ Simulate Output ▸ My Custom RGB.
|
||||
@@ -468,6 +470,7 @@ impl Default for Prefs {
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
recent_fonts: vec![],
|
||||
effects_favorites: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
custom_rgb: crate::viewer::CustomRgb::default(),
|
||||
comp_presets: vec![],
|
||||
@@ -597,6 +600,9 @@ impl Prefs {
|
||||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
self.comp_presets.retain(CompPreset::is_valid);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.effects_favorites.retain(|id| !id.is_empty() && seen.insert(id.clone()));
|
||||
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
|
||||
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
|
||||
self.startup.window_graphics = Startup::default().window_graphics;
|
||||
}
|
||||
@@ -715,6 +721,7 @@ impl Prefs {
|
||||
recent_footage: std::mem::take(&mut self.recent_footage),
|
||||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
effects_favorites: std::mem::take(&mut self.effects_favorites),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
comp_presets: std::mem::take(&mut self.comp_presets),
|
||||
..d
|
||||
@@ -916,6 +923,23 @@ impl Prefs {
|
||||
push_mru(&mut self.recent_fonts, family, 30);
|
||||
}
|
||||
|
||||
/// Is effect `id` starred in Effects & Presets?
|
||||
pub fn is_effect_favorite(&self, id: &str) -> bool {
|
||||
self.effects_favorites.iter().any(|f| f == id)
|
||||
}
|
||||
|
||||
/// Star effect `id` in Effects & Presets, or un-star it; true when it is starred now.
|
||||
pub fn toggle_effect_favorite(&mut self, id: &str) -> bool {
|
||||
if self.is_effect_favorite(id) {
|
||||
self.effects_favorites.retain(|f| f != id);
|
||||
false
|
||||
} else {
|
||||
self.effects_favorites.push(id.to_string());
|
||||
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
|
||||
self.is_effect_favorite(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display).
|
||||
pub fn recent_fonts_shown(&self) -> &[String] {
|
||||
let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len());
|
||||
@@ -933,6 +957,9 @@ impl Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
|
||||
const MAX_EFFECTS_FAVORITES: usize = 1000;
|
||||
|
||||
fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
|
||||
list.retain(|x| x != v);
|
||||
list.insert(0, v.to_string());
|
||||
|
||||
@@ -362,8 +362,15 @@ impl Session {
|
||||
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
|
||||
let folder = self.prefs.export.default_output_folder.trim();
|
||||
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
|
||||
let base =
|
||||
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
|
||||
// A project saved under a bare filename has the empty path as its parent. It sits in the
|
||||
// working directory, so resolve against that rather than falling through to the
|
||||
// unsaved-project default below (#1427).
|
||||
let base = base.or_else(|| {
|
||||
self.path
|
||||
.as_deref()
|
||||
.and_then(|s| std::path::Path::new(s).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })
|
||||
});
|
||||
// Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory
|
||||
// is `/` for an app started from the macOS Finder, where nothing can be written (#446).
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);
|
||||
|
||||
@@ -225,36 +225,50 @@ impl Session {
|
||||
let prefs = self.autosave_prefs();
|
||||
let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}"));
|
||||
let plan = autosave::plan_in(self.file_ops(), &prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?;
|
||||
let path = plan.path.to_string_lossy().into_owned();
|
||||
if self.autosave.background && !cfg!(target_arch = "wasm32") {
|
||||
let project = self.project.clone();
|
||||
let config = self.config.clone();
|
||||
let job = plan.clone();
|
||||
let pending = autosave::PendingWrite::spawn(plan.path.clone(), move || {
|
||||
let json = project.to_json_compact();
|
||||
match &config {
|
||||
Some(c) => job.write(c.files(), json.as_bytes()),
|
||||
None => job.write(&crate::config::StdFiles, json.as_bytes()),
|
||||
}
|
||||
})
|
||||
.map_err(fail)?;
|
||||
let pending =
|
||||
autosave::PendingWrite::spawn(plan.path.clone(), plan.slot, self.revision, self.path.clone(), self.script_ui.project_epoch, move || {
|
||||
let json = project.to_json_compact();
|
||||
match &config {
|
||||
Some(c) => job.write(c.files(), json.as_bytes()),
|
||||
None => job.write(&crate::config::StdFiles, json.as_bytes()),
|
||||
}
|
||||
})
|
||||
.map_err(fail)?;
|
||||
self.autosave.pending = Some(pending);
|
||||
} else {
|
||||
let json = self.project.to_json_compact();
|
||||
plan.write(self.file_ops(), json.as_bytes()).map_err(fail)?;
|
||||
self.commit_autosave(path.clone(), plan.slot, self.revision);
|
||||
}
|
||||
let (path, slot) = (plan.path, plan.slot);
|
||||
let p = path.to_string_lossy().to_string();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Advance recovery bookkeeping only for a successfully written snapshot.
|
||||
fn commit_autosave(&mut self, path: String, slot: u32, revision: u64) {
|
||||
self.autosave.last_slot = Some(slot);
|
||||
self.autosave.saved_revision = Some(self.revision);
|
||||
self.autosave.last_path = Some(p.clone());
|
||||
self.autosave.saved_revision = Some(revision);
|
||||
self.autosave.last_path = Some(path);
|
||||
self.update_sentinel();
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Wait for a background auto-save ([`autosave::AutoSaveState::background`]) to land.
|
||||
pub fn autosave_wait(&mut self) -> Result<()> {
|
||||
match self.autosave.pending.take() {
|
||||
Some(w) => w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}"))),
|
||||
Some(w) => {
|
||||
w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
|
||||
// Open/New can replace the project before note_project_path collects this
|
||||
// write. Save As changes only its path. Never publish the old snapshot as the
|
||||
// new project's recovery record, even when that record's final reset fails.
|
||||
if w.project_path == self.path && w.project_epoch == self.script_ui.project_epoch {
|
||||
self.commit_autosave(w.path.to_string_lossy().into_owned(), w.slot, w.revision);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -278,7 +292,7 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
self.autosave.last = Some(now);
|
||||
if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
|
||||
if !self.is_dirty() || self.autosave.pending.is_some() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() && self.autosave_folder_override.is_none()
|
||||
|
||||
@@ -686,3 +686,50 @@ fn reopening_a_project_restores_its_active_comp() {
|
||||
o.insert("openComps".into(), open);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the
|
||||
/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714).
|
||||
#[test]
|
||||
fn reopening_a_project_restores_layer_times_and_current_time() {
|
||||
use effectcraft_project::ItemId;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("times.ecproj").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "crown"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
|
||||
comp.frame_rate = FrameRate::FPS_59_94;
|
||||
// The layer from the issue: 34f, 315f and 3033f at 59.94 fps.
|
||||
let l = comp.layers.first_mut().unwrap();
|
||||
(l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800));
|
||||
s.execute("time.set", json!({"time": 6.0})).unwrap();
|
||||
let t = s.time();
|
||||
// A second comp keeps its own time.
|
||||
let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap());
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
let tb = s.time();
|
||||
s.execute("comp.open", json!({"comp": "C"})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let l = s2.project.comp(cid).unwrap().layers.first().unwrap();
|
||||
assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)));
|
||||
assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb)));
|
||||
assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time");
|
||||
assert!(!s2.is_dirty());
|
||||
|
||||
// Hand-edited times past the end or before the start land on the last or first frame.
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5});
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute("file.open", json!({"path": path})).unwrap();
|
||||
let c = s3.project.comp(cid).unwrap();
|
||||
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
|
||||
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
|
||||
assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `keys.delete` reports the keys it removed, not the keys it was asked to remove.
|
||||
|
||||
use effectcraft_project::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
fn setup() -> (Session, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for t in 0..3 {
|
||||
s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
|
||||
}
|
||||
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
(s, layer, prop)
|
||||
}
|
||||
|
||||
fn keys_of(s: &Session, layer: u64, prop: u64) -> Vec<KeyRef> {
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys;
|
||||
keys.iter().map(|k| KeyRef { layer: LayerId(layer), prop, time: k.time }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keys_counts_only_the_keys_it_removed() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
assert_eq!(keys.len(), 3);
|
||||
// Two real keys, one of them listed twice, and a key of a property that does not exist.
|
||||
let missing = KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[1].time };
|
||||
s.state.selected_keys = vec![keys[0], keys[2], keys[0], missing];
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(2));
|
||||
assert_eq!(keys_of(&s, layer, prop), vec![keys[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keys_reports_zero_when_no_selected_key_exists() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
s.state.selected_keys = vec![KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[0].time }];
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
}
|
||||
@@ -741,3 +741,56 @@ fn effect_colour_labels_set_clear_undo_and_round_trip() {
|
||||
let l = old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone();
|
||||
assert_eq!(l.effects().unwrap().groups().next().unwrap().label, None);
|
||||
}
|
||||
|
||||
/// #1746: a preset saved from an effect property (`effects/ec.control.slider/slider`, `#2` for the second
|
||||
/// instance) applies back to the same layer instead of landing in `skipped`.
|
||||
#[test]
|
||||
fn preset_round_trips_effect_properties_by_match_name() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Slider Control");
|
||||
apply(&mut s, a, "Slider Control");
|
||||
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
|
||||
KV::Scalar(v) => v,
|
||||
ref other => panic!("not a scalar: {other:?}"),
|
||||
};
|
||||
for (n, value) in [(1, 30.0), (2, 70.0)] {
|
||||
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": value})).unwrap();
|
||||
}
|
||||
for n in [1, 2] {
|
||||
let path = std::env::temp_dir().join(format!("ec-1746-{}-{n}.ecpreset", std::process::id())).to_string_lossy().to_string();
|
||||
s.execute("prop.select", json!({"layer": a, "path": format!("effects/#{n}/slider")})).unwrap();
|
||||
s.execute("anim.savePreset", json!({"path": path})).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
let want = if n == 1 { "effects/ec.control.slider/slider" } else { "effects/ec.control.slider#2/slider" };
|
||||
assert!(saved.contains(want), "{saved}");
|
||||
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": 0.0})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
assert_eq!(slider(&s, n - 1), if n == 1 { 30.0 } else { 70.0 });
|
||||
}
|
||||
}
|
||||
|
||||
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
|
||||
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
|
||||
#[test]
|
||||
fn copied_effect_property_pastes_onto_the_same_effect() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Gaussian Blur");
|
||||
apply(&mut s, b, "Gaussian Blur");
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/blurriness", "value": 12.5})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["values"], 1, "{r}");
|
||||
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.5));
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "effects/#1/blurriness", "expression": "time * 10"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
|
||||
s.execute("edit.copyExpressionOnly", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["expressions"], 1, "{r}");
|
||||
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().expr.as_ref().unwrap().text, "time * 10");
|
||||
}
|
||||
|
||||
@@ -274,6 +274,55 @@ fn template_export_import_round_trip_with_overrides() {
|
||||
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_import_writes_media_only_into_its_media_folder() {
|
||||
let root = out_dir("ectemplate-names");
|
||||
let dir = root.join("a").join("b");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (mut s, card, _solid, _ids) = card();
|
||||
let src = dir.join("Src.ectemplate");
|
||||
s.execute("essential.exportTemplate", json!({"comp": card.0, "path": src.to_string_lossy()})).unwrap();
|
||||
let mut entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&src).unwrap());
|
||||
std::fs::remove_file(&src).unwrap();
|
||||
let abs = root.join("abs").join("x").to_string_lossy().replace('\\', "/");
|
||||
for name in [
|
||||
"../x",
|
||||
"media/../../x",
|
||||
"media/1/../../../x",
|
||||
&format!("media/{abs}"),
|
||||
&format!("media/6{abs}"),
|
||||
"media/C:\\x",
|
||||
"media/7/C:\\x",
|
||||
"media/x/clip.png",
|
||||
"media/8/sub/clip.png",
|
||||
"media/2/..",
|
||||
"media/3/",
|
||||
"media/4/clip.png",
|
||||
"media/5/clip.png",
|
||||
] {
|
||||
entries.push((name.to_string(), b"data".to_vec()));
|
||||
}
|
||||
let path = dir.join("T.ectemplate");
|
||||
std::fs::write(&path, effectcraft_lottie::zip::store(&entries)).unwrap();
|
||||
s.execute("essential.importTemplate", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
|
||||
let media = dir.join("T Media");
|
||||
fn files(d: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for e in std::fs::read_dir(d).unwrap().map(|e| e.unwrap().path()) {
|
||||
if e.is_dir() { files(&e, out) } else { out.push(e) }
|
||||
}
|
||||
}
|
||||
let mut found = vec![];
|
||||
files(&root, &mut found);
|
||||
let in_item = |f: &std::path::Path| f.parent().and_then(std::path::Path::parent) == Some(media.as_path());
|
||||
for f in &found {
|
||||
assert!(f == &path || in_item(f), "written outside an item folder of the media folder: {f:?}");
|
||||
}
|
||||
let mut names: Vec<String> = found.iter().filter(|f| in_item(f)).map(|f| f.strip_prefix(&media).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["4/clip.png", "5/clip.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_replacement_swaps_footage_per_instance() {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -77,6 +77,19 @@ fn proexr_writes_layer_prefixed_channels_and_the_composite() {
|
||||
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proexr_names_a_non_ascii_layer_with_underscores_instead_of_panicking() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "你好", "color": "#ff0000"})).unwrap();
|
||||
let dir = out_dir("frame-exr-non-ascii");
|
||||
let path = dir.join("frame.exr").to_string_lossy().to_string();
|
||||
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"__.R") && names.contains(&"__.A"), "{names:?}");
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
}
|
||||
|
||||
/// Save a 32×32 comp holding one opaque solid of `color` (an sRGB-encoded triple) as ProEXR and
|
||||
/// return the centre pixel of the composite and of the solid's own layer: `[R, G, B]` each.
|
||||
fn proexr_centre(color: [f64; 3], bit_depth: u64) -> ([f32; 3], [f32; 3]) {
|
||||
|
||||
@@ -113,7 +113,7 @@ fn import_project_merges_with_fresh_ids_and_one_undo() {
|
||||
// A relative footage path resolves next to the imported project; a missing one is reported.
|
||||
let data = t.project.items.values().find(|i| i.name == "data.json").unwrap();
|
||||
let ItemKind::Footage(f) = &data.kind else { panic!("footage") };
|
||||
assert_eq!(std::path::Path::new(&f.path), dir.join("data.json"));
|
||||
assert_eq!(std::fs::canonicalize(&f.path).unwrap(), std::fs::canonicalize(dir.join("data.json")).unwrap());
|
||||
assert_eq!(r["missingMedia"], json!(["/offline/gone.mov"]));
|
||||
|
||||
// One undo step removes all of it; redo brings it back.
|
||||
|
||||
@@ -280,6 +280,28 @@ fn import_export_helpers() {
|
||||
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
let mut s = session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut item = RenderQueueItem::new(1, cid);
|
||||
item.output.output = "clip.mp4".into();
|
||||
item.output.format = OutputFormat::H264;
|
||||
|
||||
// `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project.
|
||||
s.path = Some("./proj.ecproj".into());
|
||||
let dotted = s.resolve_output(&item).unwrap();
|
||||
|
||||
// `--project proj.ecproj`: the parent is the empty path, which must mean the same folder
|
||||
// rather than the unsaved-project default (#1427).
|
||||
s.path = Some("proj.ecproj".into());
|
||||
let bare = s.resolve_output(&item).unwrap();
|
||||
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}");
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
@@ -349,6 +371,34 @@ fn execute_file_needs_the_gate_and_asks_first() {
|
||||
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_confirm_executing_a_file_for_itself() {
|
||||
// Warn User When Executing Files: only the user answers the question (#1246).
|
||||
let mut s = session();
|
||||
let f = tmp("script-run.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let path = f.to_string_lossy().to_string();
|
||||
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_)));
|
||||
// Without the gate a script can't run files at all, confirmed or not.
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).is_err());
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
// A script asserting `confirmed` still gets the question, directly or in a batch.
|
||||
let r = s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).unwrap();
|
||||
assert!(r.get("needsConfirmation").is_some(), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
let steps = json!({"steps": [{"command": "file.executeFile", "params": {"path": path, "confirmed": true}}]});
|
||||
let r = s.execute_for_script("engine.batch", steps).unwrap();
|
||||
assert!(r.to_string().contains("needsConfirmation"), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
// The user's answer from the frontend's question still opens it.
|
||||
assert!(s.execute("file.executeFile", json!({"path": path, "confirmed": true})).unwrap().get("opened").is_some());
|
||||
assert!(opened(&mut s));
|
||||
// With the warning off a script's request opens the file without a question, as before.
|
||||
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path})).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Edit ▸ History, recent lists
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -412,6 +412,162 @@ impl Deferred {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1249: a setting changed while the analysis runs in a worker survives its result, which
|
||||
/// used to replace the whole effect with the copy the job started from.
|
||||
#[test]
|
||||
fn an_edit_made_during_an_offloaded_analysis_survives_its_result() {
|
||||
let (mut s, clip, solid) = setup(shaky);
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
|
||||
set_param(&mut s, clip, "stabilization/smoothness", json!(37.5));
|
||||
let off = std::sync::Arc::new(Deferred::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap();
|
||||
// While the job runs: another setting of the same effect.
|
||||
set_param(&mut s, clip, "stabilization/smoothness", json!(12.5));
|
||||
let fx = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().props.prop("effects/#1/stabilization/smoothness").unwrap().value.clone();
|
||||
off.run(s.footage.clone());
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })), "applied without a conflict");
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert_eq!(st["analyzed"], true, "the analysis landed: {st}");
|
||||
assert_eq!(fx(&s), KV::Scalar(12.5), "the edit made during the analysis stays");
|
||||
// One undo step takes the analysis back and keeps the edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(fx(&s), KV::Scalar(12.5));
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
|
||||
}
|
||||
|
||||
fn point_track_pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
([150.0 + 2.0 * f as f64, 110.0 + f as f64], 0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Hold an actual point-tracking worker request so commands can edit its target before the
|
||||
/// serialized worker reply is delivered.
|
||||
fn held_point_track() -> (Session, LayerId, Uid, std::sync::Arc<Deferred>) {
|
||||
let (mut s, clip, solid) = setup(patch_frames(point_track_pose));
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
let tracker = s.execute("track.motion", json!({})).unwrap()["tracker"].as_u64().unwrap();
|
||||
s.execute("track.setPoint", json!({"point": 1, "center": point_track_pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
|
||||
let off = std::sync::Arc::new(Deferred::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.execute_checked("track.analyze", json!({"direction": "forward"})).unwrap();
|
||||
assert_eq!(off.kinds(), [crate::offload::JobKind::Track]);
|
||||
(s, clip, tracker, off)
|
||||
}
|
||||
|
||||
fn tracked_center(s: &Session, clip: LayerId, tracker: Uid) -> Uid {
|
||||
s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().track_points().next().unwrap().get("featureCenter").unwrap().uid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_conflict_preserves_state_and_reports_completion_failure() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
let center = tracked_center(&s, clip, tracker);
|
||||
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
|
||||
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Redo survives"})).unwrap();
|
||||
assert!(s.undo());
|
||||
let before = s.project.clone();
|
||||
let state = serde_json::to_value(&s.state).unwrap();
|
||||
let steps = |s: &Session| {
|
||||
let entries =
|
||||
|v: &[(String, std::sync::Arc<effectcraft_project::Project>)]| v.iter().map(|(label, p)| (label.clone(), p.to_json())).collect::<Vec<_>>();
|
||||
(
|
||||
entries(&s.history.undo),
|
||||
entries(&s.history.redo),
|
||||
s.history.branches.iter().map(|b| (b.parent.to_json(), entries(&b.steps))).collect::<Vec<_>>(),
|
||||
s.history.merge_key.clone(),
|
||||
)
|
||||
};
|
||||
let history = steps(&s);
|
||||
let inbox = s.offloaded(crate::offload::JobKind::Track).unwrap().inbox.clone();
|
||||
off.run(s.footage.clone());
|
||||
let (done, replies): (Vec<_>, Vec<_>) = inbox.take().into_iter().partition(|r| matches!(r, crate::offload::WorkerReply::Done));
|
||||
assert_eq!(done.len(), 1);
|
||||
for reply in replies {
|
||||
inbox.push(reply);
|
||||
}
|
||||
s.drain_events();
|
||||
assert!(s.poll_offload());
|
||||
let pending = s.offloaded(crate::offload::JobKind::Track).unwrap();
|
||||
assert!(pending.progress.error.as_ref().is_some_and(|e| e.contains("nothing was applied")));
|
||||
assert!(!pending.progress.finished);
|
||||
assert!(s.offload_log.is_empty(), "the error is kept before completion is logged");
|
||||
// The current completion remains identifiable after the bounded log evicts an old entry.
|
||||
s.offload_log = vec![(crate::offload::JobKind::Warp, None); 32];
|
||||
for reply in done {
|
||||
inbox.push(reply);
|
||||
}
|
||||
assert!(s.poll_offload());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.project, &before), "a rejected result changes no document content");
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(steps(&s), history);
|
||||
let errors: Vec<_> = s
|
||||
.drain_events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
crate::Event::Toast { error: true, message } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(errors.len(), 1, "the apply rejection is reported once: {errors:?}");
|
||||
assert!(errors[0].contains("nothing was applied"));
|
||||
assert_eq!(s.offload_log.len(), 32);
|
||||
assert_eq!(s.offload_log_start, 1);
|
||||
let (kind, error) = s.offload_log.last().unwrap();
|
||||
assert_eq!(*kind, crate::offload::JobKind::Track);
|
||||
assert!(error.as_ref().is_some_and(|e| e.contains("nothing was applied")), "a rejected result must report failure to JobWaiter");
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().name, "Redo survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_disjoint_edit_reports_success_and_keeps_one_undo_step() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Live track"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": clip.0, "prop": tracker, "value": false})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
off.run(s.footage.clone());
|
||||
s.drain_events();
|
||||
assert!(s.poll_offload());
|
||||
let layer = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
let track = layer.props.find_group(tracker).unwrap();
|
||||
assert_eq!(track.name, "Live track");
|
||||
assert!(!track.enabled);
|
||||
assert_eq!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.len(), 25);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.offload_log.last(), Some(&(crate::offload::JobKind::Track, None)));
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })));
|
||||
let applied = s.project.to_json();
|
||||
assert!(s.undo());
|
||||
let track = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap();
|
||||
assert_eq!(track.name, "Live track");
|
||||
assert!(!track.enabled);
|
||||
assert!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.project.to_json(), applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_conflict_does_not_fail_a_concurrent_analysis() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
let effect = s.execute("effect.apply", json!({"effect": "Warp Stabilizer", "layers": [clip.0]})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
s.execute_checked("warp.analyze", json!({"layer": clip.0, "effect": effect})).unwrap();
|
||||
assert_eq!(off.kinds(), [crate::offload::JobKind::Track, crate::offload::JobKind::Warp]);
|
||||
let center = tracked_center(&s, clip, tracker);
|
||||
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
|
||||
off.run(s.footage.clone());
|
||||
assert!(s.poll_offload());
|
||||
let track = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Track).unwrap();
|
||||
assert!(track.1.as_ref().is_some_and(|e| e.contains("nothing was applied")));
|
||||
let warp = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Warp).unwrap();
|
||||
assert!(warp.1.is_none(), "the other analysis completed successfully: {warp:?}");
|
||||
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": effect})).unwrap()["analyzed"], true);
|
||||
assert!(s.active_comp().unwrap().layer(clip).unwrap().props.find(center).unwrap().keys.is_empty());
|
||||
}
|
||||
|
||||
/// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the
|
||||
/// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the
|
||||
/// analysis job sends the plan's summary with its result, and a settings change has the plan
|
||||
|
||||
@@ -111,6 +111,38 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
#[test]
|
||||
fn null_video_switch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap();
|
||||
assert!(!layer(&s, n).switches.video);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, n).switches.video);
|
||||
// Alt-click on the solid's eye hides the null too; on the null's, the solid.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap();
|
||||
assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video);
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
// The hidden null still carries its child.
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO);
|
||||
let child = c.layer(effectcraft_project::LayerId(a)).unwrap();
|
||||
let (m, _) = ctx.layer_to_comp(child);
|
||||
let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0));
|
||||
assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
@@ -433,8 +465,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
@@ -706,6 +740,21 @@ fn sequence_layers() {
|
||||
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its
|
||||
/// font by family, PostScript or full name.
|
||||
#[test]
|
||||
fn fonts_named_by_their_postscript_or_full_name_are_not_missing() {
|
||||
let mut s = comp();
|
||||
// The first three name bundled faces by family, PostScript and full name; no face has the fourth.
|
||||
let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"];
|
||||
let ids: Vec<u64> = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap();
|
||||
let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect();
|
||||
assert_eq!(listed, [(ids[3], fonts[3])], "{r}");
|
||||
let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap();
|
||||
assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_project_ops() {
|
||||
let mut s = comp();
|
||||
@@ -1396,6 +1445,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch()
|
||||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||||
}
|
||||
|
||||
/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion
|
||||
/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI,
|
||||
/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers
|
||||
/// as they are, so the source layer's and the target parent's transforms don't move them.
|
||||
#[test]
|
||||
fn a_copied_path_pastes_onto_position_as_a_motion_path() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
// A moved and scaled source: converting to comp space would give 2v + (200, 100).
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap();
|
||||
let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]);
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap();
|
||||
// B's parent would shift a converted Position by (0, −50).
|
||||
let parent = solid(&mut s, "#0000ff");
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap();
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap();
|
||||
let motion = |s: &Session, id: u64| {
|
||||
let l = layer(s, id);
|
||||
let pr = l.props.prop("transform/position").unwrap().clone();
|
||||
pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::<Vec<_>>()
|
||||
};
|
||||
let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| {
|
||||
let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]];
|
||||
assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}");
|
||||
for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() {
|
||||
assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}");
|
||||
assert_eq!(*roving, i != 0 && i != 4, "key {i} roving");
|
||||
assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}");
|
||||
}
|
||||
assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI");
|
||||
assert!(keys.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent");
|
||||
};
|
||||
// A Mask Path keyframe, pasted at 1 s.
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
check(motion(&s, b), 1.0);
|
||||
// The same path as an unanimated mask, pasted at 5 s onto an unparented layer.
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let c = solid(&mut s, "#ffffff");
|
||||
s.execute("time.set", json!({"time": 5.0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
check(motion(&s, c), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||||
let mut s = comp();
|
||||
@@ -1492,6 +1595,40 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() {
|
||||
assert!(s.execute("edit.paste", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes,
|
||||
/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629).
|
||||
#[test]
|
||||
fn pasted_path_operator_goes_below_the_shapes_it_acts_on() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]);
|
||||
}
|
||||
|
||||
/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer
|
||||
/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681).
|
||||
#[test]
|
||||
fn pasted_merge_paths_keeps_the_targets_paint() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap();
|
||||
let before = opaque_centroid(&s).2;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]);
|
||||
assert_eq!(opaque_centroid(&s).2, before);
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
|
||||
/// original and named "Rectangle 2", not the layer (#227).
|
||||
#[test]
|
||||
@@ -1556,6 +1693,31 @@ fn interpret_footage_loop_extends_video_layers() {
|
||||
assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0)));
|
||||
}
|
||||
|
||||
/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and
|
||||
/// a freshly dragged layer plays every cycle.
|
||||
#[test]
|
||||
fn interpret_footage_loop_extends_sequence_layers() {
|
||||
let mut s = comp();
|
||||
let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap();
|
||||
let id = effectcraft_project::ItemId(p);
|
||||
// Make it a sequence like the importer's grouped PNGs.
|
||||
s.edit("Seq", None, |proj, _| {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) {
|
||||
f.kind = effectcraft_project::FootageKind::Sequence;
|
||||
f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect();
|
||||
f.sync_sequence_duration();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
let dur = (layer.out_point - layer.in_point).seconds();
|
||||
assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_guides_follow_the_comp_size() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path};
|
||||
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with};
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
|
||||
use crate::{Session, autosave};
|
||||
@@ -212,6 +212,34 @@ fn prefs_commands_get_set_reset_and_persist() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Effects & Presets' starred effects live in the settings (#1692): they come back in the next
|
||||
/// session, un-starring sticks too, Reset All keeps them, and a hand-edited list loses its
|
||||
/// repeats and empty ids on load.
|
||||
#[test]
|
||||
fn effects_favorites_persist_across_sessions() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
assert!(s.prefs.toggle_effect_favorite("ADBE Gaussian Blur 2"));
|
||||
assert!(s.prefs.toggle_effect_favorite("ADBE Glo2"));
|
||||
s.save_prefs();
|
||||
let reopen = |store: &Arc<MemoryConfig>| {
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s.load_settings();
|
||||
s
|
||||
};
|
||||
let mut s2 = reopen(&store);
|
||||
assert_eq!(s2.prefs.effects_favorites, ["ADBE Gaussian Blur 2", "ADBE Glo2"]);
|
||||
assert!(!s2.prefs.toggle_effect_favorite("ADBE Glo2"), "un-starred");
|
||||
s2.save_prefs();
|
||||
let mut s3 = reopen(&store);
|
||||
assert_eq!(s3.prefs.effects_favorites, ["ADBE Gaussian Blur 2"]);
|
||||
s3.execute("prefs.reset", json!({})).unwrap();
|
||||
assert!(s3.prefs.is_effect_favorite("ADBE Gaussian Blur 2"), "Reset All keeps the stars");
|
||||
let edited = store.read(PREFS_FILE).unwrap().replace("\"effectsFavorites\": [", "\"effectsFavorites\": [\"\", \"ADBE Glo2\", \"ADBE Glo2\", ");
|
||||
store.write(PREFS_FILE, &edited).unwrap();
|
||||
assert_eq!(reopen(&store).prefs.effects_favorites, ["ADBE Glo2", "ADBE Gaussian Blur 2"]);
|
||||
}
|
||||
|
||||
/// Composition Settings presets the user saves live in the settings: they persist, a save with
|
||||
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
|
||||
/// settings file) are dropped on load.
|
||||
@@ -548,6 +576,276 @@ fn frontend_commands_are_bindable_with_panel_scope() {
|
||||
|
||||
// ---------------------------------------------------------------- auto-save / recovery
|
||||
|
||||
/// The names in `d`, sorted.
|
||||
fn names(d: &std::path::Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_overwrites_a_long_valid_filename() {
|
||||
let d = tmp("atomic-long-name");
|
||||
// The old `<name>.tmp` fits a 255-byte component limit for this 251-byte project name.
|
||||
// Including the full name plus a process id and counter made an otherwise valid save fail.
|
||||
let name = format!("{}.ecproj", "x".repeat(244));
|
||||
assert_eq!(name.len(), 251);
|
||||
let f = d.join(&name);
|
||||
std::fs::write(&f, b"original project").unwrap();
|
||||
let replacement = b"replacement project\0bytes\xff";
|
||||
atomic_write(&f, replacement).unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), replacement);
|
||||
assert_eq!(names(&d), [name], "no temporary file is left after overwriting the project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_atomic_writes_each_publish_a_whole_file() {
|
||||
// Writers of the same file used to share one `.tmp`: they truncated each other's data and
|
||||
// the later rename failed once the first had moved it away (#1233).
|
||||
let d = tmp("atomic-concurrent");
|
||||
let f = d.join("shared.ecproj");
|
||||
for round in 0..10 {
|
||||
let contents: Vec<Vec<u8>> = (0..8u8).map(|w| vec![b'a' + w; 256 * 1024 + usize::from(w) * 1000]).collect();
|
||||
std::thread::scope(|sc| {
|
||||
for c in &contents {
|
||||
let f = &f;
|
||||
sc.spawn(move || atomic_write(f, c).expect("every writer succeeds"));
|
||||
}
|
||||
});
|
||||
let got = std::fs::read(&f).unwrap();
|
||||
assert!(contents.contains(&got), "round {round}: the file is one writer's whole data ({} bytes)", got.len());
|
||||
assert_eq!(names(&d), ["shared.ecproj"], "round {round}: no temporary file is left");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_does_not_follow_a_link_at_the_old_temp_name() {
|
||||
let d = tmp("atomic-link");
|
||||
let f = d.join("p.ecproj");
|
||||
let victim = d.join("victim.txt");
|
||||
std::fs::write(&victim, b"keep").unwrap();
|
||||
// The temporary name used to be `<name>.tmp`, opened with File::create (following links).
|
||||
std::os::unix::fs::symlink(&victim, d.join("p.ecproj.tmp")).unwrap();
|
||||
atomic_write(&f, b"new project").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "the link's target is untouched");
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"new project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
struct AutosaveTestStore {
|
||||
config: DirConfig,
|
||||
fail_next: std::sync::atomic::AtomicBool,
|
||||
gate: std::sync::Mutex<Option<(std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>)>>,
|
||||
release_on_prefs: std::sync::Mutex<Option<std::sync::mpsc::Sender<()>>>,
|
||||
fail_reset_for: std::sync::Mutex<Option<String>>,
|
||||
failed_resets: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl ConfigStore for AutosaveTestStore {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
self.config.read(name)
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
// note_project_path saves preferences after the project/path switch, before joining
|
||||
// the pending autosave. Release that writer at this exact transition, without sleeps.
|
||||
if name == PREFS_FILE
|
||||
&& let Some(release) = self.release_on_prefs.lock().unwrap().take()
|
||||
{
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
if name == autosave::SENTINEL
|
||||
&& let Some(path) = self.fail_reset_for.lock().unwrap().as_ref()
|
||||
{
|
||||
let record: autosave::Sentinel = serde_json::from_str(data).unwrap();
|
||||
if record.project.as_ref() == Some(path) && record.autosave.is_none() {
|
||||
self.failed_resets.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
return Err(std::io::Error::other("injected final recovery reset failure"));
|
||||
}
|
||||
}
|
||||
self.config.write(name, data)
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
self.config.remove(name)
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
self.config.dir()
|
||||
}
|
||||
fn files(&self) -> &dyn crate::config::FileOps {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::config::FileOps for AutosaveTestStore {
|
||||
fn list(&self, dir: &std::path::Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
crate::config::StdFiles.list(dir)
|
||||
}
|
||||
fn write(&self, path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some((started, release)) = self.gate.lock().unwrap().take() {
|
||||
started.send(()).unwrap();
|
||||
release.recv().unwrap();
|
||||
}
|
||||
if self.fail_next.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(std::io::Error::other("injected autosave write failure"));
|
||||
}
|
||||
crate::config::StdFiles.write(path, data)
|
||||
}
|
||||
fn remove(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
crate::config::StdFiles.remove(path)
|
||||
}
|
||||
fn is_file(&self, path: &std::path::Path) -> bool {
|
||||
crate::config::StdFiles.is_file(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn background_autosave_session(dir: &std::path::Path) -> (Session, Arc<AutosaveTestStore>) {
|
||||
let store = Arc::new(AutosaveTestStore {
|
||||
config: DirConfig::new(dir.join("config")),
|
||||
fail_next: Default::default(),
|
||||
gate: Default::default(),
|
||||
release_on_prefs: Default::default(),
|
||||
fail_reset_for: Default::default(),
|
||||
failed_resets: Default::default(),
|
||||
});
|
||||
let mut s = session_with_comp();
|
||||
s.config = Some(store.clone());
|
||||
s.prefs.auto_save.enabled = true;
|
||||
s.prefs.auto_save.interval_minutes = 1;
|
||||
s.prefs.auto_save.location = "custom".into();
|
||||
s.prefs.auto_save.folder = dir.join("saves").to_string_lossy().into_owned();
|
||||
s.autosave.background = true;
|
||||
s.begin_recovery();
|
||||
(s, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_autosave_failure_retries_the_same_revision_and_retains_recovery() {
|
||||
let dir = tmp("autosave-retry");
|
||||
let (mut s, store) = background_autosave_session(&dir);
|
||||
let first = s.autosave_now().unwrap();
|
||||
s.autosave_wait().unwrap();
|
||||
let committed = s.autosave.saved_revision;
|
||||
s.execute("layer.newNull", json!({"name": "work to recover"})).unwrap();
|
||||
let revision = s.revision;
|
||||
store.fail_next.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(s.autosave_tick(1000.0).is_none());
|
||||
let failed_path = s.autosave_tick(1061.0).unwrap().unwrap();
|
||||
assert!(s.autosave_wait().unwrap_err().to_string().contains("injected autosave write failure"));
|
||||
assert_eq!(s.autosave.saved_revision, committed);
|
||||
assert_eq!(s.autosave.last_slot, Some(1));
|
||||
assert_eq!(s.autosave.last_path.as_deref(), Some(first.as_str()));
|
||||
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
|
||||
assert_eq!(sentinel.autosave.as_deref(), Some(first.as_str()));
|
||||
assert!(!std::path::Path::new(&failed_path).is_file());
|
||||
let retried = s.autosave_tick(1122.0).expect("retry without another edit").unwrap();
|
||||
assert_eq!(retried, failed_path, "failed writes do not rotate the slot");
|
||||
s.autosave_wait().unwrap();
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.autosave.saved_revision, Some(revision));
|
||||
assert_eq!(s.autosave.last_slot, Some(2));
|
||||
assert!(std::fs::read_to_string(&retried).unwrap().contains("work to recover"));
|
||||
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
|
||||
assert_eq!(sentinel.autosave.as_deref(), Some(retried.as_str()));
|
||||
assert!(s.autosave_tick(1183.0).is_none(), "committed revision needs no duplicate write");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_autosave_commits_only_the_snapshot_that_finished() {
|
||||
let dir = tmp("autosave-pending");
|
||||
let (mut s, store) = background_autosave_session(&dir);
|
||||
let (started, started_rx) = std::sync::mpsc::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel();
|
||||
*store.gate.lock().unwrap() = Some((started, release_rx));
|
||||
let revision = s.revision;
|
||||
let path = s.autosave_now().unwrap();
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
assert!(s.autosave_tick(1000.0).is_none());
|
||||
let saved = s.autosave.saved_revision;
|
||||
let last_path = s.autosave.last_path.clone();
|
||||
let sentinel: autosave::Sentinel = serde_json::from_str(&store.read(autosave::SENTINEL).unwrap()).unwrap();
|
||||
s.execute("layer.newNull", json!({"name": "edited during save"})).unwrap();
|
||||
assert!(s.autosave_tick(1061.0).is_none(), "a pending write must not block the next tick or launch a duplicate");
|
||||
release.send(()).unwrap();
|
||||
s.autosave_wait().unwrap();
|
||||
assert_eq!(saved, None, "an in-flight save is not committed");
|
||||
assert_eq!(last_path, None);
|
||||
assert_eq!(sentinel.autosave, None);
|
||||
assert_eq!(s.autosave.saved_revision, Some(revision), "completion must not mark newer edits as saved");
|
||||
assert_eq!(s.autosave.last_path.as_deref(), Some(path.as_str()));
|
||||
assert!(!std::fs::read_to_string(&path).unwrap().contains("edited during save"));
|
||||
let newer = s.autosave_tick(1122.0).unwrap().unwrap();
|
||||
s.autosave_wait().unwrap();
|
||||
assert_eq!(s.autosave.saved_revision, Some(s.revision));
|
||||
assert!(std::fs::read_to_string(newer).unwrap().contains("edited during save"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn autosave_project_transition_with_failed_reset(command: &str, reopen: bool) {
|
||||
let dir = tmp("autosave-project-transition");
|
||||
let a = dir.join("A.ecproj").to_string_lossy().into_owned();
|
||||
let b = if reopen { a.clone() } else { dir.join("B.ecproj").to_string_lossy().into_owned() };
|
||||
let (mut s, store) = background_autosave_session(&dir);
|
||||
s.execute("file.saveAs", json!({"path": a})).unwrap();
|
||||
if !reopen {
|
||||
let mut other = session_with_comp();
|
||||
other.execute("layer.newNull", json!({"name": "project B"})).unwrap();
|
||||
other.execute("file.saveAs", json!({"path": b})).unwrap();
|
||||
}
|
||||
let b_bytes = std::fs::read(&b).unwrap();
|
||||
s.execute("layer.newNull", json!({"name": "unsaved project A"})).unwrap();
|
||||
s.update_sentinel();
|
||||
let (started, started_rx) = std::sync::mpsc::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel();
|
||||
*store.gate.lock().unwrap() = Some((started, release_rx));
|
||||
let snapshot = s.autosave_now().unwrap();
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
assert!(!s.autosave.pending.as_ref().unwrap().is_finished());
|
||||
let previous_record = store.read(autosave::SENTINEL).unwrap();
|
||||
*store.fail_reset_for.lock().unwrap() = Some(b.clone());
|
||||
*store.release_on_prefs.lock().unwrap() = Some(release);
|
||||
s.execute(command, json!({"path": b})).unwrap();
|
||||
assert_eq!(s.path.as_deref(), Some(b.as_str()));
|
||||
assert_eq!(store.failed_resets.load(std::sync::atomic::Ordering::SeqCst), 1, "the final recovery reset really failed");
|
||||
assert_eq!(
|
||||
store.read(autosave::SENTINEL).as_deref(),
|
||||
Some(previous_record.as_str()),
|
||||
"completion must not publish A's snapshot under the switched project"
|
||||
);
|
||||
assert_eq!(s.autosave.last_path, None);
|
||||
assert_eq!(s.autosave.saved_revision, None);
|
||||
if command == "file.open" {
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b_bytes, "opening B must not alter its file bytes");
|
||||
}
|
||||
let prefs = s.prefs.clone();
|
||||
drop(s); // Simulate an unclean exit, leaving the failed-reset sentinel in place.
|
||||
let mut restart = Session { config: Some(store), prefs, ..Default::default() };
|
||||
let recovery = restart.begin_recovery().expect("previous project remains recoverable");
|
||||
assert_eq!(recovery.project.as_deref(), Some(a.as_str()));
|
||||
assert_eq!(recovery.autosave.as_deref(), Some(snapshot.as_str()));
|
||||
assert!(recovery.dirty);
|
||||
assert!(std::fs::read_to_string(snapshot).unwrap().contains("unsaved project A"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_another_project_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
|
||||
autosave_project_transition_with_failed_reset("file.open", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_the_same_path_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
|
||||
autosave_project_transition_with_failed_reset("file.open", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_during_autosave_keeps_recovery_consistent_if_the_reset_fails() {
|
||||
autosave_project_transition_with_failed_reset("file.saveAs", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_survives_a_crash_mid_write() {
|
||||
let d = tmp("atomic");
|
||||
@@ -555,12 +853,12 @@ fn atomic_write_survives_a_crash_mid_write() {
|
||||
atomic_write(&f, b"version 1").unwrap();
|
||||
// The writer dies halfway through the temporary file.
|
||||
let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| {
|
||||
std::fs::write(tmp, &data[..5])?;
|
||||
std::io::Write::write_all(tmp, &data[..5])?;
|
||||
Err(std::io::Error::other("power cut"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"version 1");
|
||||
assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up");
|
||||
assert_eq!(names(&d), ["p.ecproj"], "the partial temp file is cleaned up");
|
||||
// A temp file left by a hard kill doesn't count as an auto-save.
|
||||
std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap();
|
||||
assert!(autosave::existing(&d, "p").is_empty());
|
||||
|
||||
@@ -476,3 +476,110 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The first path item in a shape layer's Contents, at any depth: (uid, match_id). A new shape
|
||||
/// layer nests it in a group ("Rectangle 1" ▸ "Rectangle Path 1"), as After Effects does.
|
||||
fn first_shape(s: &Session, l: u64) -> (u64, String) {
|
||||
fn walk(g: &effectcraft_project::PropGroup) -> Option<(u64, String)> {
|
||||
if ["rect", "ellipse", "star", "path"].contains(&g.match_id.as_str()) {
|
||||
return Some((g.uid, g.match_id.clone()));
|
||||
}
|
||||
g.groups().find_map(walk)
|
||||
}
|
||||
walk(layer(s, l).props.sub("contents").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn shape_path_of(s: &Session, l: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let ly = layer(s, l);
|
||||
let g = ly.props.sub("contents").unwrap().find_group(uid).unwrap();
|
||||
let _ = &g;
|
||||
g.get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Convert To Bezier Path on a shape layer's parametric path gives an editable Path 1 with the
|
||||
/// same geometry, as After Effects does (#932). The command only turned RotoBezier off for masks,
|
||||
/// so a Rectangle, Ellipse or Polystar path could not be converted at all.
|
||||
#[test]
|
||||
fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
let convert = |args: serde_json::Value| {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", args).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, was) = first_shape(&s, l);
|
||||
assert_ne!(was, "path", "it starts out parametric");
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
let out = s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
assert_eq!(out["paths"].as_u64(), Some(1));
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path", "it is a bezier path now");
|
||||
// The new path is selected, so its vertices can be edited straight away.
|
||||
assert_eq!(s.state.selected_props, vec![(LayerId(l), new_uid)]);
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
assert!(path.closed, "a closed shape stays closed");
|
||||
(s, l, was, path)
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(first_shape(&s, l).1, was, "undo restores the parametric shape");
|
||||
|
||||
// An ellipse is four cubic arcs: a vertex on each axis, with tangents of 0.5523 x the radius
|
||||
// (the usual circle approximation). Straight lines here would be a diamond.
|
||||
let (_, _, _, ell) = convert(json!({"kind": "ellipse", "size": [120, 80]}));
|
||||
assert_eq!(ell.vertices, vec![[0.0, -40.0], [60.0, 0.0], [0.0, 40.0], [-60.0, 0.0]]);
|
||||
let kappa = 4.0 / 3.0 * (std::f64::consts::SQRT_2 - 1.0);
|
||||
for (i, want) in [60.0 * kappa, 40.0 * kappa, 60.0 * kappa, 40.0 * kappa].into_iter().enumerate() {
|
||||
let t = ell.in_tangents[i];
|
||||
assert!((radius(t) - want).abs() < 1e-6, "vertex {i}: tangent {t:?} should be {want}");
|
||||
}
|
||||
|
||||
// A five-point star alternates the outer and inner radius, so ten vertices.
|
||||
let (_, _, _, star) = convert(json!({"kind": "star", "size": [120, 120]}));
|
||||
assert_eq!(star.vertices.len(), 10);
|
||||
for (i, v) in star.vertices.iter().enumerate() {
|
||||
let want = if i % 2 == 0 { 60.0 } else { 30.0 };
|
||||
assert!((radius(*v) - want).abs() < 1e-6, "vertex {i} at {v:?} should be {want} from the centre");
|
||||
}
|
||||
|
||||
// A polygon has one vertex per point, all at the outer radius.
|
||||
let (_, _, _, poly) = convert(json!({"kind": "polygon", "size": [120, 120]}));
|
||||
assert!(poly.vertices.len() >= 3);
|
||||
assert!(poly.vertices.iter().all(|v| (radius(*v) - 60.0).abs() < 1e-6), "{:?}", poly.vertices);
|
||||
assert_ne!(poly.vertices.len(), star.vertices.len(), "a polygon is not a star");
|
||||
}
|
||||
|
||||
/// The shape's own values are read, not defaults: Roundness rounds the corners and Position moves
|
||||
/// the path, both at the time the conversion is made.
|
||||
#[test]
|
||||
fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
// Properties are addressed by their own uid.
|
||||
let pr_uid = |s: &Session, id: &str| {
|
||||
let ly = layer(s, l);
|
||||
ly.props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid
|
||||
};
|
||||
let (round, pos) = (pr_uid(&s, "roundness"), pr_uid(&s, "position"));
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{round}"), "value": 20.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{pos}"), "value": [10.0, 5.0]})).unwrap();
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
|
||||
// Rounded corners need more than four vertices, and curves between them.
|
||||
assert!(path.vertices.len() > 4, "roundness rounds the corners: {} vertices", path.vertices.len());
|
||||
assert!(path.in_tangents.iter().chain(&path.out_tangents).any(|t| *t != [0.0, 0.0]), "with curves");
|
||||
// Position moves the path: the bounds are centred on it, not on the origin.
|
||||
let xs: Vec<f64> = path.vertices.iter().map(|v| v[0]).collect();
|
||||
let ys: Vec<f64> = path.vertices.iter().map(|v| v[1]).collect();
|
||||
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! undo/redo and serde round-trips).
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Node, Project, PropGroup};
|
||||
use effectcraft_project::{LayerId, Node, Project, PropGroup};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
@@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() {
|
||||
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_text_presets_keep_the_catalog_and_application_independent() {
|
||||
let expected = [
|
||||
("typewriter", "Typewriter"),
|
||||
("fadeUpCharacters", "Fade Up Characters"),
|
||||
("bounceInWords", "Bounce In Words"),
|
||||
("trackingIn", "Tracking In"),
|
||||
("scramble", "Scramble"),
|
||||
("blurIn", "Blur In"),
|
||||
("jitter", "Jitter"),
|
||||
("dropInLines", "Drop In Lines"),
|
||||
];
|
||||
let (mut s, layer) = session();
|
||||
let catalog = s.execute("text.presets", json!({})).unwrap();
|
||||
let mut returned = catalog.clone();
|
||||
returned[0]["name"] = json!("Edited copy");
|
||||
let project = s.project.clone();
|
||||
for _ in 0..2 {
|
||||
let names = crate::text_presets();
|
||||
assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::<Vec<_>>(), expected);
|
||||
for (id, _) in expected {
|
||||
let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap();
|
||||
assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project, project, "{id}: undo restores the document");
|
||||
}
|
||||
assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm text-preset listing benchmark"]
|
||||
fn text_preset_listing_benchmark() {
|
||||
let old = || {
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap();
|
||||
let presets = v["presets"].as_array().unwrap().clone();
|
||||
presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(old(), crate::text_presets());
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(old());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(crate::text_presets());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_have_text_animation_entries() {
|
||||
let (s, _) = session();
|
||||
@@ -186,3 +244,27 @@ fn menus_have_text_animation_entries() {
|
||||
assert!(s.is_enabled("layer.enablePerChar3D"));
|
||||
assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator");
|
||||
}
|
||||
|
||||
/// Duplicate with a Range Selector selected under a text animator copies the selector, not the
|
||||
/// whole text layer (#1665); a selected animator is copied the same way.
|
||||
#[test]
|
||||
fn duplicate_copies_selected_text_selector_not_the_layer() {
|
||||
let (mut s, t) = session();
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap();
|
||||
let anims = |s: &Session| s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.group("text/animators").unwrap().clone();
|
||||
let selectors = |s: &Session| -> Vec<u64> { anims(s).groups().next().unwrap().sub("selectors").unwrap().groups().map(|g| g.uid).collect() };
|
||||
let sel = selectors(&s)[0];
|
||||
|
||||
s.execute("prop.select", json!({"layer": t, "prop": sel, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "no second text layer");
|
||||
assert_eq!(selectors(&s).len(), 2, "the selector is copied");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(selectors(&s), vec![sel]);
|
||||
|
||||
let anim = anims(&s).groups().next().unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
assert_eq!(anims(&s).groups().count(), 2, "the animator is copied");
|
||||
}
|
||||
|
||||
@@ -317,6 +317,66 @@ fn fonts_filter_variable_families() {
|
||||
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
|
||||
}
|
||||
|
||||
/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style
|
||||
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
|
||||
/// `<family without spaces>-<style without spaces>`.
|
||||
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
|
||||
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
|
||||
for (i, (style, w)) in instances.iter().enumerate() {
|
||||
let id = 256 + 2 * i as u16;
|
||||
strings.push((id, (*style).into()));
|
||||
strings.push((id + 1, format!("{}-{}", squash(family), squash(style))));
|
||||
fvar.extend(u16s(&[id, 0]));
|
||||
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
|
||||
fvar.extend(u16s(&[id + 1]));
|
||||
}
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
|
||||
/// text layer set in one (by style, or by the instance's PostScript name as After Effects scripts
|
||||
/// do) is laid out at the instance's axis values; an axis set on the text still wins.
|
||||
#[test]
|
||||
fn variable_font_named_instances_are_styles() {
|
||||
const FAMILY: &str = "EffectCraft Named Instance Test";
|
||||
assert_eq!(effectcraft_text::fonts::add_font_data(instanced_variable_font(FAMILY, &[("Light", 300.0), ("Regular", 400.0), ("Black", 900.0)])).len(), 1);
|
||||
let (mut s, t) = session("Hi");
|
||||
let fonts = s.execute("text.fonts", json!({"query": FAMILY})).unwrap();
|
||||
assert_eq!(fonts["families"][0]["styles"], json!(["Light", "Regular", "Black"]), "{fonts}");
|
||||
let wght = |s: &Session| -> Vec<Vec<(String, f32)>> { effectcraft_text::layout_doc(&doc(s, t)).glyphs.iter().map(|g| g.variations.clone()).collect() };
|
||||
let black = vec![("wght".to_string(), 900.0)];
|
||||
s.execute("layer.setText", json!({"layer": t, "font": FAMILY, "style": "Black"})).unwrap();
|
||||
let by_style = wght(&s);
|
||||
assert!(!by_style.is_empty() && by_style.iter().all(|v| v == &black), "{by_style:?}");
|
||||
s.execute("layer.setText", json!({"layer": t, "font": "EffectCraftNamedInstanceTest-Black", "style": "Regular"})).unwrap();
|
||||
let by_ps = wght(&s);
|
||||
assert!(!by_ps.is_empty() && by_ps.iter().all(|v| v == &black), "{by_ps:?}");
|
||||
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
|
||||
assert!(wght(&s).iter().all(|v| v == &[("wght".to_string(), 650.0)]), "{:?}", wght(&s));
|
||||
}
|
||||
|
||||
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
|
||||
/// channel, MCP) accepts them and requires none (#520).
|
||||
#[test]
|
||||
|
||||
@@ -108,6 +108,85 @@ fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
|
||||
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_selected_keys_excludes_deselected_properties_and_stale_values() {
|
||||
let (mut s, source) = setup();
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/opacity", "value": 25})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/opacity");
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.value_clipboard.len(), 1);
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
animate(&mut s, source, "transform/rotation", &[(0.0, json!(15)), (1.0, json!(90))]);
|
||||
animate(&mut s, source, "transform/scale", &[(0.0, json!([50, 60, 100])), (1.0, json!([150, 160, 100]))]);
|
||||
select_prop_keys(&mut s, source, "transform/scale");
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/position", "time": 0}, {"layer": source, "path": "transform/rotation", "time": 1}], "add": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/scale", "time": 0}, {"layer": source, "path": "transform/scale", "time": 1}], "toggle": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(s.state.selected_props.len(), 3);
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(2));
|
||||
assert!(s.state.value_clipboard.is_empty());
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
undo_redo_roundtrip(
|
||||
&mut s,
|
||||
|s| {
|
||||
for (path, time, value) in [("transform/position", 2.0, KV::Vec3([10.0, 20.0, 0.0])), ("transform/rotation", 3.0, KV::Scalar(90.0))] {
|
||||
let p = prop(s, target, path);
|
||||
assert_eq!(p.keys.len(), 1);
|
||||
let k = &p.keys[0];
|
||||
assert_eq!((k.time.seconds(), &k.value), (time, &value));
|
||||
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier));
|
||||
assert_eq!(k.in_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
assert_eq!(k.out_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
}
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(prop(s, target, "transform/scale").value, KV::Vec3([100.0; 3]));
|
||||
assert!(prop(s, target, "transform/scale").keys.is_empty());
|
||||
assert_eq!(prop(s, target, "transform/opacity").value, KV::Scalar(100.0));
|
||||
},
|
||||
|s| {
|
||||
assert!(prop(s, target, "transform/position").keys.is_empty());
|
||||
assert!(prop(s, target, "transform/rotation").keys.is_empty());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_mixed_animated_and_static_properties_keeps_the_static_value() {
|
||||
let (mut s, source) = setup();
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/rotation", "value": 45})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/position");
|
||||
s.execute("prop.select", json!({"layer": source, "path": "transform/rotation", "add": true})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!({"keys": 2, "values": 1}));
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(45.0));
|
||||
let position = prop(&s, target, "transform/position");
|
||||
assert_eq!(
|
||||
position.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect::<Vec<_>>(),
|
||||
vec![(2.0, KV::Vec3([10.0, 20.0, 0.0])), (3.0, KV::Vec3([30.0, 40.0, 0.0]))]
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(prop(&s, target, "transform/position").keys.is_empty());
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_removes_keys_and_paste_restores_them() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -331,6 +410,77 @@ fn pen_tool_draws_edits_and_closes_a_mask() {
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
/// Cut every mask path's tangent arrays in the saved project to one entry (static values and
|
||||
/// keyframes alike), then open the result: the shape of a hand-edited or foreign file (#1250).
|
||||
fn reopen_with_short_tangents(s: &mut Session) {
|
||||
fn cut(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.contains_key("vertices") {
|
||||
for k in ["in_tangents", "out_tangents"] {
|
||||
if let Some(Value::Array(a)) = o.get_mut(k) {
|
||||
a.truncate(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
o.values_mut().for_each(cut);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(cut),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut j: Value = serde_json::from_str(&s.project.to_json()).unwrap();
|
||||
cut(&mut j);
|
||||
let p = effectcraft_project::Project::from_json(&j.to_string()).unwrap();
|
||||
s.replace_project(p, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!(path(&s).in_tangents.len(), 1, "the file really has short tangents");
|
||||
// Both used to index the tangent arrays by vertex and panic.
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap();
|
||||
let p = path(&s);
|
||||
assert_eq!(p.vertices.len(), 4);
|
||||
assert_eq!((p.in_tangents.len(), p.out_tangents.len()), (4, 4));
|
||||
assert_eq!((p.in_tangents[2], p.out_tangents[2]), ([0.0, -20.0], [0.0, 20.0]));
|
||||
assert_eq!(p.in_tangents[1], [0.0, 0.0], "a missing tangent is a corner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_vertices_of_a_loaded_path_with_short_tangents_does_not_panic() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
let p = prop(&s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!((p.vertices.len(), p.in_tangents.len(), p.out_tangents.len()), (2, 2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_animated_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 0, "point": [0, 0]})).unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [5, 5]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "index": 3, "point": [10, 90]})).unwrap();
|
||||
for k in &prop(&s, l, "masks/#1/path").keys {
|
||||
let p = k.value.as_path().unwrap();
|
||||
assert_eq!(p.vertices.len(), 4, "the other key gains the vertex too");
|
||||
assert!(p.in_tangents.len() == 4 && p.out_tangents.len() == 4, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -1193,3 +1343,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
|
||||
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
|
||||
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
|
||||
}
|
||||
|
||||
/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets
|
||||
/// that component on each of them; their other components stay their own. Before, every
|
||||
/// selected layer took the edited layer's whole value and they piled up on top of it.
|
||||
#[test]
|
||||
fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] {
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap();
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap();
|
||||
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components();
|
||||
let uid = prop(&s, a, "transform/position").uid;
|
||||
// The panels send the edited layer's value with the typed component, and which one it was.
|
||||
let undo = s.history.undo.len();
|
||||
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap();
|
||||
assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]);
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y");
|
||||
assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]);
|
||||
|
||||
// Linked Scale: both edited components go to every layer, Z stays each layer's own.
|
||||
s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap();
|
||||
let scale_uid = prop(&s, a, "transform/scale").uid;
|
||||
s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap();
|
||||
assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]);
|
||||
|
||||
// Position with Separate Dimensions on one of the other layers.
|
||||
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap();
|
||||
let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64();
|
||||
assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0));
|
||||
|
||||
// Without `dims` (agents setting a whole value) every selected layer takes the value as is.
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
|
||||
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
@@ -637,3 +637,63 @@ fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
|
||||
s.execute_checked("view.res.auto", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
}
|
||||
|
||||
/// #1685: changing Type updates the property tree in the same undo step, including when
|
||||
/// addressed by uid (as in the UI) rather than by path (scripts/control channel).
|
||||
#[test]
|
||||
fn polystar_type_switch_updates_inner_controls_and_undoes() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
|
||||
let polygon = group(&s);
|
||||
let type_uid = polygon.get("type").unwrap().uid;
|
||||
assert!(polygon.get("innerRadius").is_none());
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
|
||||
let star = group(&s);
|
||||
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(50.0));
|
||||
assert_eq!(star.get("innerRoundness").unwrap().value, KV::Scalar(0.0));
|
||||
assert_eq!(star.get("innerRadius").unwrap().ui, effectcraft_project::ParamUi::Pixels);
|
||||
assert_eq!(star.get("innerRoundness").unwrap().ui, effectcraft_project::ParamUi::Percent);
|
||||
let names: Vec<_> = star.children.iter().filter_map(|n| n.as_prop()).map(|p| p.match_id.as_str()).collect();
|
||||
assert_eq!(names, ["direction", "type", "points", "position", "rotation", "innerRadius", "outerRadius", "innerRoundness", "outerRoundness"]);
|
||||
assert_eq!(star.get("outerRadius"), polygon.get("outerRadius"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(group(&s), polygon);
|
||||
assert!(s.redo());
|
||||
assert_eq!(group(&s), star);
|
||||
// Reapplying the same Type must not reset custom inner values or allocate new ids.
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 37})).unwrap();
|
||||
let before = group(&s);
|
||||
let next = s.project.next_id;
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 0})).unwrap();
|
||||
assert_eq!(group(&s), before);
|
||||
assert_eq!(s.project.next_id, next);
|
||||
let inner_uid = before.get("innerRadius").unwrap().uid;
|
||||
s.state.selected_props = vec![(LayerId(lid), inner_uid)];
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner_uid, time: Tick::ZERO }];
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 1})).unwrap();
|
||||
assert_eq!(group(&s).get("type").unwrap().value, KV::Enum(1));
|
||||
assert!(group(&s).get("innerRadius").is_none());
|
||||
assert!(group(&s).get("innerRoundness").is_none());
|
||||
assert!(s.state.selected_props.is_empty());
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
assert!(s.undo());
|
||||
assert_eq!(group(&s), before, "undo restores the original inner values and ids");
|
||||
assert!(s.redo());
|
||||
assert!(group(&s).get("innerRadius").is_none());
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
|
||||
assert_eq!(group(&s).get("innerRadius").unwrap().value, KV::Scalar(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 27})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "time": 1, "value": 1})).unwrap();
|
||||
let star = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
|
||||
assert_eq!(star.get("type").unwrap().value_at(Tick::ZERO), KV::Enum(0));
|
||||
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
|
||||
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
@@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
}
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
|
||||
/// page: one footage item per file layer (each showing only that layer, continuously
|
||||
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
|
||||
/// rasterisable), top layer first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
@@ -287,11 +287,12 @@ pub fn import_vector_comp(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
page: u32,
|
||||
rate: effectcraft_time::FrameRate,
|
||||
duration: effectcraft_time::Tick,
|
||||
retain: bool,
|
||||
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let (rate, duration) = timing;
|
||||
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
@@ -301,24 +302,33 @@ pub fn import_vector_comp(
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let (size, offset) = if retain {
|
||||
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
|
||||
(layer.pixel_size(), offset)
|
||||
} else {
|
||||
((w, h), [0.0, 0.0])
|
||||
};
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
|
||||
page,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
|
||||
if retain && let Some(position) = l.props.prop_mut("transform/position") {
|
||||
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
|
||||
}
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8;
|
||||
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
|
||||
let comp = ctx.comp;
|
||||
let time = ctx.time;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
|
||||
@@ -48,22 +48,43 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
|
||||
/// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing
|
||||
/// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot
|
||||
/// deadlock.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn map_frames<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
map_frames_while(ks, cap, || 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.
|
||||
pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
capped: impl Fn() -> bool,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
use rayon::prelude::*;
|
||||
let Some(n) = cap.map(|n| n.max(1)) else {
|
||||
let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else {
|
||||
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 win in ks.chunks(window) {
|
||||
for (w, win) in ks.chunks(window).enumerate() {
|
||||
if !capped() {
|
||||
out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k))));
|
||||
break;
|
||||
}
|
||||
let mut imgs = Vec::with_capacity(win.len());
|
||||
for chunk in win.chunks(n) {
|
||||
imgs.par_extend(chunk.par_iter().map(|&k| render(k)));
|
||||
let mut i = 0;
|
||||
while i < win.len() {
|
||||
let end = if capped() { (i + n).min(win.len()) } else { 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)));
|
||||
}
|
||||
@@ -187,7 +208,10 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
map_frames(ks, self.gpu_cap(), |k| self.frame(comp, k), f)
|
||||
// The cap is for the GPU's memory: once it has stopped for good, frames render on the
|
||||
// CPU in parallel.
|
||||
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)
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
@@ -453,6 +477,36 @@ mod tests {
|
||||
(out, max.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_in_parallel_once_the_gpu_stops() {
|
||||
// Every frame after the GPU stopped falls back to the CPU: the cap no longer applies.
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..24).collect(),
|
||||
Some(1),
|
||||
|| live.load(Ordering::SeqCst),
|
||||
|k| {
|
||||
if k == 1 {
|
||||
live.store(false, Ordering::SeqCst);
|
||||
}
|
||||
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if k >= 2 {
|
||||
max.fetch_max(c, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
now.fetch_sub(1, Ordering::SeqCst);
|
||||
k * 10
|
||||
},
|
||||
|_, v| v,
|
||||
)
|
||||
});
|
||||
assert_eq!(out, (0..24).map(|k| k * 10).collect::<Vec<_>>());
|
||||
assert!(max.load(Ordering::SeqCst) > 1, "frames after the GPU stopped ran one at a time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_frames_keep_their_order() {
|
||||
let want: Vec<u64> = (0..40).map(|k| k * 10).collect();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging
|
||||
//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It
|
||||
//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's
|
||||
//! rate) renders every source frame once, and the H.264 output has that rate and frame count.
|
||||
//!
|
||||
//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing
|
||||
//! their index in binary (eight bars, white for a set bit).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
|
||||
const W: u32 = 128;
|
||||
const H: u32 = 32;
|
||||
const BARS: u32 = 8;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds.
|
||||
fn ms_rounded(rate: FrameRate, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num);
|
||||
(0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect()
|
||||
}
|
||||
|
||||
/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms);
|
||||
/// frame k shows k in binary.
|
||||
fn write_movie(path: &Path, durations: &[u32]) {
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
cfg.bframes = 0;
|
||||
cfg.keyint = 30;
|
||||
cfg.scenecut = false;
|
||||
let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder");
|
||||
let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]);
|
||||
let mut packets = Vec::new();
|
||||
for k in 0..durations.len() {
|
||||
let y: Vec<u8> = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect();
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 };
|
||||
packets.extend(enc.try_encode(&frame, k as i64).expect("encode"));
|
||||
}
|
||||
packets.extend(enc.flush());
|
||||
assert_eq!(packets.len(), durations.len(), "one access unit per frame");
|
||||
let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC");
|
||||
let file = std::fs::File::create(path).expect("create");
|
||||
let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux");
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track");
|
||||
for (p, &duration) in packets.iter().zip(durations) {
|
||||
mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample");
|
||||
}
|
||||
mux.finish().expect("finish");
|
||||
}
|
||||
|
||||
/// The frame index a decoded frame shows.
|
||||
fn shown(img: &Image) -> u32 {
|
||||
(0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum()
|
||||
}
|
||||
|
||||
fn frame_count(f: &Footage) -> i64 {
|
||||
f.frame_rate.frame_at(f.duration - Tick(1)) + 1
|
||||
}
|
||||
|
||||
/// Every frame of a movie in order, as indices.
|
||||
fn frames_of(f: &Footage) -> Vec<u32> {
|
||||
let pool = MediaPool::new();
|
||||
(0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect()
|
||||
}
|
||||
|
||||
/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate
|
||||
/// and duration), render it to H.264 and return the output's footage.
|
||||
fn comp_from_footage(path: &Path, out: &Path) -> Footage {
|
||||
let footage = effectcraft_media::probe(path).expect("probe");
|
||||
let mut p = Project::default();
|
||||
let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone()));
|
||||
let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate");
|
||||
let mut comp = Comp::new(W, H, rate, footage.duration);
|
||||
comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None));
|
||||
let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let pool = MediaPool::new();
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let out_path = out.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &pool,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: &out_path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames");
|
||||
effectcraft_media::probe(out).expect("probe the render")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() {
|
||||
let d = out_dir("ms_rounded");
|
||||
for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, frames));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage");
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into();
|
||||
let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes");
|
||||
assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory");
|
||||
let all: Vec<u32> = (0..frames as u32).collect();
|
||||
assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once");
|
||||
|
||||
let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num)));
|
||||
assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render");
|
||||
assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration");
|
||||
assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() {
|
||||
let d = out_dir("ms_rounded_controls");
|
||||
for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, 60));
|
||||
assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate);
|
||||
}
|
||||
// Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands.
|
||||
let path = d.join("variable.mp4");
|
||||
write_movie(&path, &[40, 45, 50].repeat(20));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate);
|
||||
}
|
||||
@@ -613,3 +613,28 @@ fn wav_and_aiff_audio_only() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`.
|
||||
#[test]
|
||||
fn rendered_aiff_imports_back() {
|
||||
use effectcraft_export::render_queue::AudioFormat;
|
||||
let d = out_dir("aiff-import");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] {
|
||||
// 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration.
|
||||
let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) };
|
||||
let path = d.join(name);
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz");
|
||||
// Tone on the left, silence on the right: the two channels are read as two.
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-9
@@ -37,8 +37,12 @@ pub enum Req {
|
||||
LayerLookup { comp: u64, key: Key },
|
||||
/// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`.
|
||||
LayerInfo { comp: u64, layer: u64 },
|
||||
/// `[path, isGroup, name, matchName, numChildren]` or null.
|
||||
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]` or null.
|
||||
Child { comp: u64, layer: u64, group: String, key: Key },
|
||||
/// `propertyGroup(up)` of the property or group at `path`: the group `up` levels above it as
|
||||
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]`, `"layer"` when that is the
|
||||
/// layer itself, or null above the layer.
|
||||
Ancestor { comp: u64, layer: u64, path: String, up: i64 },
|
||||
/// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`.
|
||||
PropInfo { comp: u64, layer: u64, path: String },
|
||||
/// Value at comp time `t`; `pre` = keyframes only (no expression).
|
||||
@@ -256,6 +260,18 @@ fn alias(key: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A child node as expression data (see [`Req::Child`]); `index` is 1-based among its siblings.
|
||||
fn node_resp(path: String, n: &Node, index: usize) -> Resp {
|
||||
Resp::List(vec![
|
||||
Resp::Str(path),
|
||||
Resp::Bool(n.as_group().is_some()),
|
||||
Resp::Str(n.name().into()),
|
||||
Resp::Str(n.match_id().into()),
|
||||
Resp::Num(n.as_group().map_or(0, |g| g.children.len()) as f64),
|
||||
Resp::Num(index as f64),
|
||||
])
|
||||
}
|
||||
|
||||
fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> {
|
||||
match key {
|
||||
Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)),
|
||||
@@ -353,15 +369,33 @@ impl<'a> Resolver<'a> {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
let g = if group.is_empty() { &l.props } else { l.props.group(group)? };
|
||||
let n = find_child(g, key)?;
|
||||
let index = g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1;
|
||||
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
|
||||
let count = n.as_group().map_or(0, |g| g.children.len());
|
||||
Resp::List(vec![
|
||||
Resp::Str(path),
|
||||
Resp::Bool(n.as_group().is_some()),
|
||||
Resp::Str(n.name().into()),
|
||||
Resp::Str(n.match_id().into()),
|
||||
Resp::Num(count as f64),
|
||||
])
|
||||
node_resp(path, n, index)
|
||||
}
|
||||
Req::Ancestor { comp, layer, path, up } => {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
|
||||
let uid = match self.prop(*comp, *layer, path) {
|
||||
Some((_, p)) => p.uid,
|
||||
None => l.props.node(path)?.uid(),
|
||||
};
|
||||
let full = l.props.path_of(uid)?;
|
||||
let segs: Vec<&str> = full.split('/').collect();
|
||||
let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
|
||||
if up == segs.len() {
|
||||
return Some(Resp::Str("layer".into()));
|
||||
}
|
||||
let keep = segs.len() - up;
|
||||
let group_path = segs.get(..keep)?.join("/");
|
||||
let parent = match keep {
|
||||
1 => &l.props,
|
||||
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
|
||||
};
|
||||
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
|
||||
let index = parent.children.iter().position(|c| c.uid() == uid)?;
|
||||
let n = parent.children.get(index)?;
|
||||
node_resp(group_path, n, index + 1)
|
||||
}
|
||||
Req::PropInfo { comp, layer, path } => {
|
||||
let (l, p) = self.prop(*comp, *layer, path)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100];
|
||||
|
||||
// Request ops (must match runtime.rs).
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
|
||||
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15;
|
||||
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15, __ANCESTOR = 16;
|
||||
|
||||
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
time = t;
|
||||
@@ -261,7 +261,10 @@ function noise(x) {
|
||||
function timeToFrames(t, fps, isDuration) {
|
||||
if (t === undefined) t = time + thisComp.displayStartTime;
|
||||
if (fps === undefined) fps = 1 / __S.fd;
|
||||
return Math.floor(t * fps + 1e-6);
|
||||
var frames = t * fps;
|
||||
// The 1e-6 keeps a whole number of frames that floating point overshoots (31/24 - 5/24) whole.
|
||||
if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames + 1e-6) : Math.ceil(frames - 1e-6);
|
||||
return Math.floor(frames + 1e-6);
|
||||
}
|
||||
|
||||
function framesToTime(frames, fps) {
|
||||
@@ -424,6 +427,18 @@ function __child(c, l, group, key) {
|
||||
return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]);
|
||||
}
|
||||
|
||||
// `propertyGroup(countUp)` of the property or group at `path`: the group `countUp` levels above
|
||||
// it (default 1), the layer at the top (#1619).
|
||||
function __ancestor(c, l, path, countUp) {
|
||||
var n = countUp === undefined ? 1 : __v(countUp);
|
||||
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
|
||||
n = Math.floor(n);
|
||||
var info = __h(__ANCESTOR, c, l, path, n);
|
||||
if (info === 'layer') return new Layer(c, l);
|
||||
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
|
||||
return __mkGroup(c, l, info);
|
||||
}
|
||||
|
||||
// Comp ------------------------------------------------------------------------------------------
|
||||
|
||||
function Comp(c) { this.__c = c; this.__i = null; }
|
||||
@@ -642,6 +657,8 @@ var __GroupProto = {
|
||||
get name() { return this.__info[2]; },
|
||||
get matchName() { return this.__info[3]; },
|
||||
get numProperties() { return this.__info[4]; },
|
||||
get propertyIndex() { return this.__info[5]; },
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
get active() { return true; },
|
||||
toString: function () { return '[object PropertyGroup]'; },
|
||||
};
|
||||
@@ -727,6 +744,7 @@ Prop.prototype = {
|
||||
get numKeys() { return this.__info[4].length; },
|
||||
get propertyIndex() { return this.__info[7]; },
|
||||
get matchName() { return this.__info[8]; },
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
__wrap: function (v) {
|
||||
var kind = this.__info[2];
|
||||
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
|
||||
|
||||
@@ -186,6 +186,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
},
|
||||
14 => Req::Project,
|
||||
15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? },
|
||||
16 => Req::Ancestor { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)?, up: num(args, 4, ctx)? as i64 },
|
||||
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
|
||||
};
|
||||
let found = FRAMES.with(|f| {
|
||||
|
||||
@@ -1285,3 +1285,82 @@ fn layer_control_property_stands_for_its_layer() {
|
||||
assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0);
|
||||
assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_durations_away_from_zero() {
|
||||
let f = fx();
|
||||
// Adobe's public expression reference distinguishes durations from absolute times.
|
||||
// Expected integer counts are literal values, independent of the implementation.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, true)", -1.0),
|
||||
("timeToFrames(-0.05, 24, true)", -2.0),
|
||||
("timeToFrames(0, 24, true)", 0.0),
|
||||
("timeToFrames(-0.125, 24, true)", -3.0),
|
||||
("timeToFrames(0.125, 24, true)", 3.0),
|
||||
("timeToFrames(0.01, 24, true)", 1.0),
|
||||
("timeToFrames(0.05, 24, true)", 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_absolute_times_toward_negative_infinity() {
|
||||
let f = fx();
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, false)", -1.0),
|
||||
("timeToFrames(-0.05, 24, false)", -2.0),
|
||||
("timeToFrames(0, 24, false)", 0.0),
|
||||
("timeToFrames(-0.125, 24, false)", -3.0),
|
||||
("timeToFrames(0.125, 24, false)", 3.0),
|
||||
("timeToFrames(0.01, 24, false)", 0.0),
|
||||
("timeToFrames(0.05, 24, false)", 1.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_default_arguments() {
|
||||
let f = fx();
|
||||
// The fixture's composition is 30 fps with displayStartTime = 0.
|
||||
for (expression, evaluation_time, expected) in [
|
||||
("timeToFrames(0.01, 24)", 0.0, 0.0),
|
||||
("timeToFrames(-0.01, 24)", 0.0, -1.0),
|
||||
("timeToFrames(0.05)", 0.0, 1.0),
|
||||
("timeToFrames(-0.05)", 0.0, -2.0),
|
||||
("timeToFrames()", 0.0, 0.0),
|
||||
("timeToFrames()", 0.05, 1.0),
|
||||
("timeToFrames()", -0.05, -2.0),
|
||||
("timeToFrames(undefined, 24, true)", 0.05, 2.0),
|
||||
("timeToFrames(0.05, undefined, true)", 0.0, 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
let f = fx();
|
||||
// Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames('0.05', '24', false)", 1.0),
|
||||
("timeToFrames('0.05', '24', true)", 2.0),
|
||||
("timeToFrames('-0.05', '24', true)", -2.0),
|
||||
("timeToFrames(null, 24, true)", 0.0),
|
||||
("timeToFrames(0.05, null, true)", 0.0),
|
||||
("timeToFrames(0.05, 0, true)", 0.0),
|
||||
("timeToFrames(31 / 24 - 5 / 24, 24, true)", 26.0),
|
||||
("timeToFrames(5 / 24 - 31 / 24, 24, true)", -26.0),
|
||||
("timeToFrames(-1e-10, 24, false)", 0.0),
|
||||
("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0),
|
||||
("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0),
|
||||
("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
|
||||
@@ -78,6 +78,8 @@ const SEEDS: &[&str] = &[
|
||||
"mask('Mask 1').maskOpacity",
|
||||
"mask('Mask 1').maskPath.points()[0]",
|
||||
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
|
||||
"thisProperty.propertyGroup(1).propertyIndex * 10 + thisProperty.propertyGroup(1).numProperties",
|
||||
"effect(1)(1).propertyGroup(1).propertyGroup(2).name",
|
||||
"marker.numKeys > 0 ? marker.key(1).time : 0",
|
||||
"marker.nearestKey(time).comment",
|
||||
"thisComp.marker.key(1).time",
|
||||
@@ -130,7 +132,7 @@ const TOKENS: &[&str] = &[
|
||||
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
|
||||
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
|
||||
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
|
||||
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
".style", ".setText(", ".propertyGroup(", ".propertyIndex", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
];
|
||||
|
||||
/// Numbers swapped in for numeric literals.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `propertyGroup(countUp)` on properties and property groups, and a group's `propertyIndex`
|
||||
//! (#1619): name-independent references up the property tree, as in After Effects.
|
||||
|
||||
use super::*;
|
||||
|
||||
const SCALE_1: &str = "contents/#1/transform/scale";
|
||||
const SCALE_2: &str = "contents/#2/transform/scale";
|
||||
|
||||
/// The test comp with a second shape group "Rectangle 2" on layer "Shape", and "Rectangle 1"'s
|
||||
/// Transform ▸ Scale keyed 0 → 100 over 0–1 s.
|
||||
fn staggered() -> Fx {
|
||||
let mut f = fx();
|
||||
let g = group_push(&mut f.p, |ids| {
|
||||
let rect = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
|
||||
build::shape_group(ids, "Rectangle 2", vec![rect])
|
||||
});
|
||||
let shape = f.layer_mut(f.shape);
|
||||
shape.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
shape.props.prop_mut(SCALE_1).unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec2([0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec2([100.0, 100.0]))];
|
||||
f
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
fn shape_num(&self, path: &str, text: &str) -> f64 {
|
||||
match self.ev(self.shape, path, &format!("var r = ({text}); [r, r]"), 0.0) {
|
||||
Ok(v) => v.as_vec2()[0],
|
||||
Err(e) => panic!("{text}: {e}"),
|
||||
}
|
||||
}
|
||||
fn shape_err(&self, path: &str, text: &str) -> String {
|
||||
match self.ev(self.shape, path, text, 0.0) {
|
||||
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_group_stagger_from_the_adobe_community_answer() {
|
||||
let f = staggered();
|
||||
// thisProperty = Scale, propertyGroup(1) = Transform, propertyGroup(2) = the shape group.
|
||||
let text = "offset = .1;\nid = thisProperty.propertyGroup(2).propertyIndex;\ncontent(\"Rectangle 1\").transform.scale.valueAtTime(time-id*offset)";
|
||||
let v = f.ev(f.shape, SCALE_2, text, 0.5).unwrap().as_vec2();
|
||||
// "Rectangle 2" is the second group in Contents: Rectangle 1's scale 0.2 s earlier.
|
||||
assert_close!(v[0], 30.0);
|
||||
assert_close!(v[1], 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_walks_up_from_the_evaluated_property() {
|
||||
let f = staggered();
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup().name == 'Transform' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).name == 'Rectangle 2' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).name == 'Contents' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_1, "thisProperty.propertyGroup(2).propertyIndex"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).propertyIndex"), 2.0);
|
||||
// A shape group holds Blend Mode, Contents and Transform, in After Effects' order.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyIndex"), 3.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).numProperties"), 3.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).numProperties"), 2.0);
|
||||
// Groups walk on: Transform → group → Contents.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(2).name == 'Contents' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(1).propertyIndex"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_reaches_the_layer() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
let s = staggered();
|
||||
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_of_an_effect_parameter_is_the_effect() {
|
||||
let f = fx();
|
||||
let path = "effects/#1/slider";
|
||||
let ev = |text: &str| f.ev(f.a, path, text, 0.0).unwrap().as_f64();
|
||||
assert_close!(ev("thisProperty.propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisProperty.propertyGroup(2).name == 'Effects' ? 1 : 0"), 1.0);
|
||||
// The name-independent rig: read the effect this parameter belongs to by its own name.
|
||||
assert_close!(ev("effect(thisProperty.propertyGroup(1).name)('Slider') + 1"), 43.0);
|
||||
assert_close!(ev("thisProperty.propertyGroup(1).propertyIndex"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_on_other_layers_properties_and_groups() {
|
||||
let f = staggered();
|
||||
// From layer B: another layer's property and group, reached by name.
|
||||
let ev = |text: &str| f.ev(f.b, ROT, text, 0.0).unwrap().as_f64();
|
||||
assert_close!(ev("thisComp.layer('A').effect(1)('Slider').propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').propertyIndex"), 2.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').transform.scale.propertyGroup(2).propertyIndex"), 2.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').content('Fill 1').propertyGroup(2).name == 'Rectangle 1' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').propertyGroup(2).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('A').effect('Slider Control').propertyIndex"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('A').mask('Mask 1').propertyGroup(1).numProperties"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_out_of_range_is_an_error_not_a_crash() {
|
||||
let f = staggered();
|
||||
for text in [
|
||||
"thisProperty.propertyGroup(0)",
|
||||
"thisProperty.propertyGroup(-1)",
|
||||
"thisProperty.propertyGroup(5)",
|
||||
"thisProperty.propertyGroup(1e300)",
|
||||
"thisProperty.propertyGroup(NaN)",
|
||||
"thisProperty.propertyGroup('x')",
|
||||
"thisProperty.propertyGroup(1).propertyGroup(4)",
|
||||
] {
|
||||
let e = f.shape_err(SCALE_2, text);
|
||||
assert!(e.contains("propertyGroup"), "{text}: {e}");
|
||||
}
|
||||
let e = f.err("thisProperty.propertyGroup(3)");
|
||||
assert!(e.contains("propertyGroup"), "{e}");
|
||||
// A fractional count rounds down, as an index does.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2.7).propertyIndex"), 2.0);
|
||||
}
|
||||
@@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
|
||||
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
|
||||
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
|
||||
let mats = s.materials.iter().flat_map(|m| {
|
||||
let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 | (m.accepts_lights as u32) << 2 | (m.receives_shadows as u32) << 3 | (m.alpha_mode << 8);
|
||||
let flags = (m.double_sided as u32)
|
||||
| (m.unlit as u32) << 1
|
||||
| (m.accepts_lights as u32) << 2
|
||||
| (m.receives_shadows as u32) << 3
|
||||
| (m.shadows_only as u32) << 4
|
||||
| (m.alpha_mode << 8);
|
||||
[
|
||||
m.base[0],
|
||||
m.base[1],
|
||||
|
||||
+164
-15
@@ -140,6 +140,74 @@ pub struct TransferStats {
|
||||
pub readback_bytes: u64,
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` trimmed; empty or whitespace-only counts as unset (an empty
|
||||
/// substring would otherwise match the first adapter and skip the power preference).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn wanted_adapter_name(var: Option<&str>) -> Option<&str> {
|
||||
var.map(str::trim).filter(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// Index of the first adapter whose name contains `want`, compared case-insensitively.
|
||||
/// Only the name is matched, as in wgpu (the backend is chosen with `WGPU_BACKEND`).
|
||||
/// `Err` lists each distinct adapter once as `name (backend)`, so a typo or an
|
||||
/// OS/driver-specific name is easy to spot.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn match_adapter_name(want: &str, adapters: &[(String, String)]) -> Result<usize, String> {
|
||||
if adapters.is_empty() {
|
||||
return Err(format!("WGPU_ADAPTER_NAME={want:?}: no adapters on the enabled backends"));
|
||||
}
|
||||
let w = want.to_lowercase();
|
||||
adapters.iter().position(|(n, _)| n.to_lowercase().contains(&w)).ok_or_else(|| {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
for l in adapters.iter().map(|(n, b)| format!("{n} ({b})")) {
|
||||
if !list.contains(&l) {
|
||||
list.push(l);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"no adapter name matches WGPU_ADAPTER_NAME={want:?} (matched against the name only; \
|
||||
select a backend with WGPU_BACKEND); available: [{}]",
|
||||
list.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter named by `WGPU_ADAPTER_NAME` (case-insensitive substring, like wgpu's
|
||||
/// `initialize_adapter_from_env`, which panics when none matches). `None` when unset or empty.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn adapter_by_name(instance: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
let var = std::env::var("WGPU_ADAPTER_NAME").ok();
|
||||
let want = wanted_adapter_name(var.as_deref())?;
|
||||
let mut all = instance.enumerate_adapters(wgpu::Backends::all()).await;
|
||||
let adapters: Vec<(String, String)> = all
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
(i.name, format!("{:?}", i.backend))
|
||||
})
|
||||
.collect();
|
||||
Some(match_adapter_name(want, &adapters).map(|i| all.swap_remove(i)))
|
||||
}
|
||||
/// `WGPU_POWER_PREF` (`low` / `high` / `none`), default HighPerformance. wgpu ignores an
|
||||
/// unknown value silently; warn so a typo is noticed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::from_env().unwrap_or_else(|| {
|
||||
if let Ok(v) = std::env::var("WGPU_POWER_PREF") {
|
||||
log::warn!("gpu: ignoring WGPU_POWER_PREF={v:?} (expected low, high or none)");
|
||||
}
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn adapter_by_name(_: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Transfers {
|
||||
fn count(&self, up: bool, bytes: usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
@@ -774,19 +842,28 @@ impl GpuContext {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Match eframe's backend override so CLI and headless acceptance tests
|
||||
// exercise the backend requested for the native viewer.
|
||||
desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends);
|
||||
// Honour the same wgpu env vars as the viewer (WGPU_BACKEND, WGPU_DX12_COMPILER,
|
||||
// WGPU_VALIDATION, WGPU_DEBUG, WGPU_POWER_PREF), plus WGPU_ADAPTER_NAME (CLI/headless
|
||||
// only for now; the viewer ignores it). Not full parity: the viewer also changes
|
||||
// instance flags and has a GL fallback.
|
||||
// Note: with_env() also resets memory_budget_thresholds to their default (unused here).
|
||||
desc = desc.with_env();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let (adapter, by_name) = match adapter_by_name(&instance).await {
|
||||
Some(a) => (a?, true),
|
||||
None => (
|
||||
instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref_from_env(), ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?,
|
||||
false,
|
||||
),
|
||||
};
|
||||
let limits = adapter.limits();
|
||||
let required_limits = wgpu::Limits {
|
||||
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
|
||||
@@ -796,7 +873,10 @@ impl GpuContext {
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
.map_err(|e| match by_name {
|
||||
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
|
||||
false => format!("no device: {e}"),
|
||||
})?;
|
||||
let context = GpuContext::new(&adapter, device, queue)?;
|
||||
// This path owns the device; install handlers only after construction succeeds.
|
||||
crate::readback::install(&context.device, &context.readbacks);
|
||||
@@ -917,6 +997,12 @@ impl GpuContext {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Entries in the layer upload cache.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn upload_entries(&self) -> usize {
|
||||
self.uploads.lock().map(|u| u.map.len()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
|
||||
/// layers upload once, not every frame.
|
||||
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
|
||||
@@ -939,14 +1025,14 @@ impl GpuContext {
|
||||
u.bytes -= old.bytes;
|
||||
}
|
||||
u.bytes += bytes;
|
||||
// Drop textures of freed buffers, then the least recently used over budget.
|
||||
// Drop textures of freed buffers at once (a layer whose buffer changes every frame
|
||||
// makes a new one each time, and nothing can hit the old one again), then the least
|
||||
// recently used over budget.
|
||||
let budget = self.upload_budget();
|
||||
if u.bytes > budget {
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
@@ -1386,3 +1472,66 @@ impl<'g> Enc<'g> {
|
||||
pub(crate) fn mip_levels(w: u32, h: u32) -> u32 {
|
||||
u32::BITS - w.max(h).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adapter_name_tests {
|
||||
use super::{match_adapter_name, wanted_adapter_name};
|
||||
|
||||
fn adapters() -> Vec<(String, String)> {
|
||||
[("NVIDIA GeForce GTX 1070", "Vulkan"), ("Intel(R) UHD Graphics 630", "Dx12"), ("llvmpipe (LLVM 19.1.7, 256 bits)", "Gl")]
|
||||
.map(|(n, b)| (n.into(), b.into()))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_case_insensitive_substring() {
|
||||
assert_eq!(match_adapter_name("intel", &adapters()), Ok(1));
|
||||
assert_eq!(match_adapter_name("GTX 1070", &adapters()), Ok(0));
|
||||
assert_eq!(match_adapter_name("LLVMPIPE", &adapters()), Ok(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_match_wins() {
|
||||
assert_eq!(match_adapter_name("i", &adapters()), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_name_is_unset_and_value_is_trimmed() {
|
||||
assert_eq!(wanted_adapter_name(None), None);
|
||||
assert_eq!(wanted_adapter_name(Some("")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" ")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" intel ")), Some("intel"));
|
||||
assert_eq!(match_adapter_name(wanted_adapter_name(Some(" intel ")).unwrap(), &adapters()), Ok(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_name_and_backend() {
|
||||
let e = match_adapter_name("NoSuch", &adapters()).unwrap_err();
|
||||
assert!(e.contains("\"NoSuch\""), "{e}");
|
||||
assert!(e.contains("Intel(R) UHD Graphics 630 (Dx12)"), "{e}");
|
||||
assert!(e.contains("NVIDIA GeForce GTX 1070 (Vulkan)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_is_not_part_of_the_match_and_message_says_so() {
|
||||
let e = match_adapter_name("dx12", &adapters()).unwrap_err();
|
||||
assert!(e.contains("name only"), "{e}");
|
||||
assert!(e.contains("WGPU_BACKEND"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_identical_name_and_backend_once() {
|
||||
let mut a = adapters();
|
||||
a.insert(2, ("Intel(R) UHD Graphics 630".into(), "Dx12".into()));
|
||||
a.push(("Intel(R) UHD Graphics 630".into(), "Vulkan".into()));
|
||||
let e = match_adapter_name("NoSuch", &a).unwrap_err();
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Dx12)").count(), 1, "{e}");
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Vulkan)").count(), 1, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_adapters_has_its_own_message() {
|
||||
let e = match_adapter_name("x", &[]).unwrap_err();
|
||||
assert!(e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let dir = ctx.params.f("direction").to_radians();
|
||||
let speed = ctx.params.f("speed");
|
||||
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(e, h.abs().ceil() as u32 + 1)?;
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("removalStyle");
|
||||
p.f[0] = [a.0 as f32, a.1 as f32, -ty as f32, tx as f32];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, 0.0];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, len as f32];
|
||||
run(e, "fp2_wire", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,10 @@ impl Accelerator for Gpu {
|
||||
self.ctx.name.clone()
|
||||
}
|
||||
|
||||
fn available(&self) -> bool {
|
||||
self.ctx.check_health().is_ok()
|
||||
}
|
||||
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
let img = self.render(r, comp, t);
|
||||
if img.is_none() {
|
||||
|
||||
@@ -288,6 +288,53 @@ struct FOut {
|
||||
@location(1) depth: u32,
|
||||
};
|
||||
|
||||
// shade::to_light: unit direction to the light (xyz) and its distance (w).
|
||||
fn to_light(kind: u32, lpos: vec3<f32>, ldir: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
|
||||
if (kind == 0u) {
|
||||
return vec4<f32>(-ldir, 1e30);
|
||||
}
|
||||
let d = lpos - pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
|
||||
}
|
||||
return vec4<f32>(d / len, len);
|
||||
}
|
||||
|
||||
// shade::shadow_only: the strongest shadow any light casts on the fragment.
|
||||
fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32>) -> f32 {
|
||||
var amount = 0.0;
|
||||
if (unlit) {
|
||||
return amount;
|
||||
}
|
||||
var n = norm3(normal);
|
||||
if (!front) {
|
||||
n = -n;
|
||||
}
|
||||
let nl_count = u32(g.fwd.w);
|
||||
for (var li = 0u; li < nl_count; li = li + 1u) {
|
||||
let lb = li * LIGHT_STRIDE;
|
||||
let l0 = lights[lb];
|
||||
let l1 = lights[lb + 1u];
|
||||
let l2 = lights[lb + 2u];
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
if (attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist) <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
let n_l = dot(n, to_l);
|
||||
if (n_l <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let mb = i.material * MAT_STRIDE;
|
||||
@@ -302,6 +349,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let unlit = (flags & 2u) != 0u;
|
||||
let accepts_lights = (flags & 4u) != 0u;
|
||||
let receives_shadows = (flags & 8u) != 0u;
|
||||
let shadows_only = (flags & 16u) != 0u;
|
||||
let alpha_mode = (flags >> 8u) & 3u;
|
||||
if (!front && !double_sided) {
|
||||
discard;
|
||||
@@ -330,6 +378,10 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
}
|
||||
var out: FOut;
|
||||
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
|
||||
if (shadows_only) {
|
||||
out.color = vec4<f32>(0.0, 0.0, 0.0, alpha * shadow_only_amount(unlit || !accepts_lights, i.normal, front, i.pos));
|
||||
return out;
|
||||
}
|
||||
if (unlit || !accepts_lights || g.env2.y < 0.5) {
|
||||
let c = rgb + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
@@ -381,22 +433,9 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
var to_l: vec3<f32>;
|
||||
var dist: f32;
|
||||
if (kind == 0u) {
|
||||
to_l = -l1.xyz;
|
||||
dist = 1e30;
|
||||
} else {
|
||||
let d = l0.xyz - i.pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
to_l = vec3<f32>(0.0, 0.0, -1.0);
|
||||
dist = 0.0;
|
||||
} else {
|
||||
to_l = d / len;
|
||||
dist = len;
|
||||
}
|
||||
}
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, i.pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
let k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
|
||||
if (k <= 0.0) {
|
||||
continue;
|
||||
|
||||
@@ -154,8 +154,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let i = u32(max(floor(hp), 0.0)) % 6u;
|
||||
let f = hp - floor(hp);
|
||||
let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f;
|
||||
let base = luminance(c);
|
||||
let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0);
|
||||
let mx = max(max(c.x, c.y), c.z);
|
||||
let mn = min(min(c.x, c.y), c.z);
|
||||
let gray = max(mn + (mx - mn) * wt, 0.0);
|
||||
var o = vec3<f32>(gray);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
|
||||
|
||||
@@ -510,8 +510,10 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c += vec3<f32>(n);
|
||||
}
|
||||
let op = P.f[8].z;
|
||||
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * op, op), P.u[0].x));
|
||||
let o = textureLoad(src, p, 0);
|
||||
// None keeps the layer's alpha, as the CPU path does (#1381).
|
||||
let op = select(P.f[8].z, P.f[8].z * o.w, P.u[0].x == 255u);
|
||||
textureStore(out, p, gen_blend(o, vec4<f32>(c * op, op), P.u[0].x));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Noise / Add Grain
|
||||
|
||||
@@ -828,7 +828,7 @@ fn fp2_io_final(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Simple Wire Removal (keying2::wire_removal)
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror).
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror, segment length).
|
||||
|
||||
fn fp2_wire_pos(base: vec2<f32>, nrm: vec2<f32>, s: f32) -> vec4<f32> {
|
||||
return sample_bilinear_clamped(src, base.x + nrm.x * s, base.y + nrm.y * s);
|
||||
@@ -851,7 +851,9 @@ fn fp2_wire(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let q = vec2<f32>(fx - a.x, fy - a.y);
|
||||
let d = q.x * nrm.x + q.y * nrm.y;
|
||||
let ad = abs(d);
|
||||
if (ad > hw + soft_w) {
|
||||
// The wire is the segment A to B: the tangent is the normal turned back by 90 degrees.
|
||||
let along = q.x * nrm.y - q.y * nrm.x;
|
||||
if (along < 0.0 || along > P.f[1].w || ad > hw + soft_w) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
|
||||
+36
-9
@@ -49,18 +49,29 @@ pub(crate) fn hold_gpu_lock() {
|
||||
|
||||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
|
||||
hold_gpu_lock();
|
||||
G.get_or_init(|| {
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu tests: no GPU adapter, skipping GPU comparisons");
|
||||
} else if let Some(g) = &g {
|
||||
eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name);
|
||||
let g = G.get_or_init(|| {
|
||||
let g = Gpu::try_headless();
|
||||
match &g {
|
||||
Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name),
|
||||
Err(e) => eprintln!("effectcraft-gpu tests: no GPU ({e}), skipping GPU comparisons"),
|
||||
}
|
||||
g
|
||||
})
|
||||
.as_ref()
|
||||
});
|
||||
require_gpu("effectcraft-gpu tests", g.as_ref())
|
||||
}
|
||||
|
||||
/// `g`, or `None` (the GPU tests skip) when there is no GPU, unless `EFFECTCRAFT_REQUIRE_GPU=1`
|
||||
/// (as in `tests_init`): then a GPU that didn't start fails the test, with why.
|
||||
pub(crate) fn require_gpu<G>(tests: &str, g: Result<G, &String>) -> Option<G> {
|
||||
match g {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) if std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|v| v == "1") => {
|
||||
panic!("{tests}: EFFECTCRAFT_REQUIRE_GPU=1 but GPU initialization failed: {e}")
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||||
@@ -1044,3 +1055,19 @@ fn static_effect_layers_draw_from_the_cache() {
|
||||
assert!(frames[1] == frames[2]);
|
||||
check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0);
|
||||
}
|
||||
|
||||
/// The layer upload cache drops the textures of freed buffers when it inserts, not only once
|
||||
/// it is over budget (an animated layer makes a new buffer every frame).
|
||||
#[test]
|
||||
fn upload_cache_drops_textures_of_freed_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let img = effectcraft_raster::Image::new(256, 256);
|
||||
let before = g.ctx.upload_entries();
|
||||
for _ in 0..6 {
|
||||
let buf = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
|
||||
let up = g.ctx.upload_buf(&buf).expect("upload");
|
||||
drop((up, buf));
|
||||
}
|
||||
let kept = g.ctx.upload_entries();
|
||||
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user