mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge main into #1601; no conflicts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd0247877c
234 files changed
+12414
-1034
No files matched your search
Generated
+103
-54
@@ -1633,18 +1633,20 @@ dependencies = [
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -2246,8 +2248,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2255,40 +2257,57 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
name = "filmcraft-apv"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-avi"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-av1",
|
||||
"filmcraft-avi",
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
@@ -2298,6 +2317,7 @@ dependencies = [
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpa",
|
||||
"filmcraft-mpeg2v",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
@@ -2305,9 +2325,11 @@ dependencies = [
|
||||
"filmcraft-opus",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-time",
|
||||
"filmcraft-vp8",
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -2315,16 +2337,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2333,8 +2364,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -2344,16 +2375,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2362,8 +2393,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2372,8 +2403,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2382,8 +2413,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2399,8 +2430,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2408,15 +2439,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
@@ -2425,10 +2457,19 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2436,31 +2477,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2473,8 +2514,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2483,16 +2524,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
||||
+21
-19
@@ -77,29 +77,29 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
|
||||
|
||||
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
|
||||
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# encoders + muxer (crates/export)
|
||||
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# container headers read for AAC channel layouts (crates/media)
|
||||
filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
|
||||
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
@@ -131,6 +131,8 @@ sys-locale = "0.3"
|
||||
# filmcraft-prores = { path = "../filmcraft/crates/prores" }
|
||||
# filmcraft-aac = { path = "../filmcraft/crates/aac" }
|
||||
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
|
||||
# filmcraft-matroska = { path = "../filmcraft/crates/matroska" }
|
||||
# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
+32
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This page is the one-page summary; the evidence is in
|
||||
@@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and
|
||||
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
|
||||
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
|
||||
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
|
||||
@@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b
|
||||
|
||||
## Languages
|
||||
|
||||
UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
|
||||
| Language | Status | % |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial (menus, part of the panels) | 29% |
|
||||
| Spanish | menus only | 16% |
|
||||
| Simplified Chinese | partial (menus, most of the panels) | 46% |
|
||||
| Spanish | menus only | 29% |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | menus only | 16% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 26% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 34% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | partial (menus, part of the panels) | 27% |
|
||||
| Japanese | partial (menus, most of the panels) | 46% |
|
||||
| German | menus only | 16% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had
|
||||
Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -117,13 +117,33 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel,
|
||||
Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items
|
||||
drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one
|
||||
animation preset can hold several (#2552). The Composition panel pans while a mask vertex is
|
||||
held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget
|
||||
(#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick
|
||||
between frames (#2593). ScriptUI drags keep their control past the window edge with
|
||||
`MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585).
|
||||
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
|
||||
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
|
||||
starts off, so existing shapes behave as before (#2489).
|
||||
- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
|
||||
`layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
|
||||
rotation of the paths above it by random amounts that change over time, up to the Transform
|
||||
values it holds. The copies of a Repeater above it wiggle independently as far as its
|
||||
Correlation allows; with the Repeater below it, they wiggle together.
|
||||
- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified
|
||||
Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus,
|
||||
tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774:
|
||||
Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese
|
||||
panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is
|
||||
46% with its 307 effect names; the overall share is ≈ 24%.
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
@@ -138,7 +158,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
|
||||
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
|
||||
- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
|
||||
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
|
||||
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
|
||||
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
|
||||
@@ -158,11 +177,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size |
|
||||
| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
|
||||
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||
@@ -1296,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
// Scripts in Scripts/Startup run before a project opens.
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
|
||||
// `startup` says (default: the last visit's project, the demo on a first visit).
|
||||
|
||||
@@ -263,6 +263,9 @@ fn main() -> eframe::Result {
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
|
||||
// a toast).
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
@@ -495,6 +498,8 @@ impl eframe::App for Desktop {
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
// Scripts in Scripts/Shutdown run with the project closed.
|
||||
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
|
||||
@@ -17,11 +17,19 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.Krueels]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
|
||||
@@ -30,10 +30,9 @@ pub struct Confinement {
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Commands that run code, act outside the project or open other applications.
|
||||
/// Commands that run code, act outside the project or open other applications, besides the
|
||||
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
|
||||
const REFUSED: &[&str] = &[
|
||||
"script.run",
|
||||
"file.runScript",
|
||||
"file.installScript",
|
||||
"file.installScriptUIPanel",
|
||||
"file.uninstallScript",
|
||||
@@ -97,6 +96,7 @@ const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath",
|
||||
|
||||
/// Commands that write files named by these parameters.
|
||||
const WRITES: &[(&str, &[&str])] = &[
|
||||
("camera.exportBlender", &["path"]),
|
||||
("file.save", &["path"]),
|
||||
("file.saveAs", &["path"]),
|
||||
("file.saveCopy", &["path"]),
|
||||
@@ -167,7 +167,7 @@ impl Confinement {
|
||||
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
|
||||
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
|
||||
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
|
||||
if REFUSED.contains(&id) {
|
||||
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
|
||||
}
|
||||
// Scripts can't grant themselves the access the user withheld
|
||||
@@ -397,6 +397,7 @@ mod tests {
|
||||
let classified = keys_of(READS, id).is_some()
|
||||
|| keys_of(WRITES, id).is_some()
|
||||
|| REFUSED.contains(&id)
|
||||
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
|
||||
|| IMPLICIT_WRITES.contains(&id)
|
||||
|| id.starts_with("renderQueue.")
|
||||
|| id == "render.addOutputModule"
|
||||
|
||||
@@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() {
|
||||
// Scripts can read and write anything: refused.
|
||||
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
|
||||
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
|
||||
for (id, params) in [
|
||||
("file.runStartupScripts", json!({})),
|
||||
("file.runShutdownScripts", json!({})),
|
||||
// ScriptUI panels run their script when they open.
|
||||
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
|
||||
("window.restoreScriptPanels", json!({})),
|
||||
] {
|
||||
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
}
|
||||
// Writes to the files the agent named: none outside.
|
||||
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
@@ -1473,3 +1485,30 @@ fn automation_roots_refuse_running_script_code() {
|
||||
call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_requires_an_absolute_path_in_a_write_root() {
|
||||
use crate::confine::Confinement;
|
||||
let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id()));
|
||||
let (read, write) = (base.join("read"), base.join("write"));
|
||||
for dir in [&read, &write] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
|
||||
let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
|
||||
assert_eq!(check(&write.join("scene.py")).unwrap(), None);
|
||||
for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
|
||||
assert!(check(&path).is_err(), "must deny {}", path.display());
|
||||
}
|
||||
for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] {
|
||||
let error = check(std::path::Path::new(path)).unwrap_err().to_string();
|
||||
assert!(error.contains("give an absolute path"), "{path}: {error}");
|
||||
}
|
||||
let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap();
|
||||
assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err());
|
||||
for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] {
|
||||
let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]});
|
||||
assert!(confinement.check_command("engine.batch", &batch).is_err());
|
||||
}
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
@@ -95,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(amount.abs().ceil() as u32 + 1);
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1));
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
|
||||
@@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
pub use sim2::{
|
||||
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
|
||||
wave_world_plan,
|
||||
@@ -312,6 +312,19 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `left` and `top` transparent pixels on those sides only.
|
||||
pub fn grow_left_top(&mut self, left: u32, top: u32) {
|
||||
self.pad_sides([left, top, 0, 0]);
|
||||
}
|
||||
/// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of
|
||||
/// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled
|
||||
/// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at
|
||||
/// its content's corner instead of the cover's resamples to different pixels (#1205).
|
||||
pub fn align(&mut self, size: [f64; 2]) {
|
||||
if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) {
|
||||
self.grow_left_top(l, t);
|
||||
}
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
|
||||
/// clamped by [`Image::effective_pad`].
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
@@ -377,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
|
||||
Some((dx as i64, dy as i64))
|
||||
}
|
||||
|
||||
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
|
||||
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
|
||||
/// skips the renderer's cover of the whole layer rectangle (#1205).
|
||||
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
|
||||
|
||||
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
|
||||
/// of the resampling pyramid used for downscaled layers.
|
||||
pub const GRID_ALIGN: u32 = 64;
|
||||
|
||||
/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None`
|
||||
/// when there is nothing to add.
|
||||
pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> {
|
||||
let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?;
|
||||
let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN);
|
||||
(l != 0 || t != 0).then_some((l, t))
|
||||
}
|
||||
|
||||
/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment
|
||||
/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so
|
||||
/// the grid phase stays. 0 once the buffer covers the layer.
|
||||
pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 {
|
||||
if ctx.adjustment {
|
||||
return 0;
|
||||
}
|
||||
want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN
|
||||
}
|
||||
|
||||
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
|
||||
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
|
||||
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
|
||||
@@ -555,6 +595,15 @@ pub struct EffectSpec {
|
||||
pub float: bool,
|
||||
}
|
||||
|
||||
impl EffectSpec {
|
||||
/// True when this effect grows its own buffer (blur/glow margins, Repeat Edge
|
||||
/// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the
|
||||
/// renderer's full-layer cover and keep the pyramid grid phase (#1205).
|
||||
pub fn skips_cover(&self) -> bool {
|
||||
SKIPS_COVER.contains(&self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
|
||||
ParamSpec { id, name, default, ui }
|
||||
}
|
||||
@@ -1583,4 +1632,24 @@ mod tests {
|
||||
assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0)));
|
||||
assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_plan_is_cover_shift_mod_grid() {
|
||||
// Content inset from the layer origin: pixel 0 maps to layer (50, 30).
|
||||
let offset = [-50.0, -30.0];
|
||||
let scale = 1.0;
|
||||
let size = [1920.0, 1080.0];
|
||||
let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap();
|
||||
assert_eq!((dx, dy), (50, 30));
|
||||
assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30)));
|
||||
assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_margin_effects_skip_cover_and_twirl_does_not() {
|
||||
for id in SKIPS_COVER {
|
||||
assert!(find(id).expect(id).skips_cover(), "{id}");
|
||||
}
|
||||
assert!(!find("ec.distort.twirl").unwrap().skips_cover());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad((s * 3.0).ceil() as u32);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
let (sx, sy) = (s * kx, s * ky);
|
||||
// Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939).
|
||||
@@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if !repeat && !ctx.adjustment {
|
||||
let reach = if frac > 0.0 { r + 1 } else { r };
|
||||
b.pad((reach * it) as u32 + 1);
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(ctx.layer_size);
|
||||
}
|
||||
let edge = repeat || ctx.adjustment;
|
||||
let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge);
|
||||
|
||||
@@ -38,7 +38,7 @@ use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
|
||||
pub use registry::{specs, sprite_plan};
|
||||
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
|
||||
pub(crate) use sprites::{combine, raster, splat};
|
||||
pub use weather::{Bubble, bubble_list, drizzle_drops};
|
||||
pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -97,6 +97,60 @@ fn bubbles_rise() {
|
||||
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
|
||||
}
|
||||
|
||||
/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
|
||||
/// in for transparent ones.
|
||||
#[test]
|
||||
fn bubbles_near_edges_show_the_layer() {
|
||||
let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
|
||||
let pale = [0, 1].map(|reflection| {
|
||||
let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
|
||||
let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
|
||||
// Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
|
||||
a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
|
||||
});
|
||||
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
|
||||
}
|
||||
|
||||
/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels
|
||||
/// (a half-resolution preview's layout).
|
||||
fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image {
|
||||
let size = 40.0;
|
||||
let s = crate::find("ec.sim.ccbubbles").unwrap();
|
||||
let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() };
|
||||
for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] {
|
||||
params.values.insert(k.into(), v);
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let mut b = Buf { img, offset: [0.0; 2], scale };
|
||||
b.pad(6);
|
||||
crate::apply(s, &ctx, b).img
|
||||
}
|
||||
|
||||
/// #2024: the same when the buffer has transparent padding around the layer: samples past the
|
||||
/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is
|
||||
/// still refracted as the pale colour.
|
||||
#[test]
|
||||
fn bubbles_near_padded_edges_show_the_layer() {
|
||||
let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]);
|
||||
let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count();
|
||||
for (scale, side) in [(1.0, 40), (0.5, 20)] {
|
||||
let counts = [0, 1].map(|reflection| {
|
||||
let a = padded_bubbles(blue(side), scale, reflection);
|
||||
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
|
||||
pale(&a)
|
||||
});
|
||||
assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)");
|
||||
}
|
||||
let mut holed = blue(40);
|
||||
for y in 12..28 {
|
||||
for x in 12..28 {
|
||||
holed.set(x, y, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drizzle_ripples() {
|
||||
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::common::{h, hs};
|
||||
use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul};
|
||||
use crate::util::layer_rect_at;
|
||||
|
||||
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
|
||||
|
||||
@@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec<Bubble> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the
|
||||
/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat
|
||||
/// the layer's edge pixels instead of reaching transparent padding around it (#2024).
|
||||
pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] {
|
||||
let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale);
|
||||
let (x0, y0) = (lx + 0.5, ly + 0.5);
|
||||
[x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32]
|
||||
}
|
||||
|
||||
pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let metal = pr.e("reflectionType") == 1;
|
||||
let shading = pr.e("shadingType");
|
||||
let list = bubble_list(ctx, &b);
|
||||
let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale);
|
||||
let src = b.img.clone();
|
||||
let mut out = Image::new(src.width, src.height);
|
||||
raster(
|
||||
@@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
|
||||
let k = if metal { 1.6 } else { -0.7 };
|
||||
let nz = (1.0 - d2).max(0.0).sqrt();
|
||||
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
|
||||
let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1));
|
||||
let p = src.sample_bilinear_clamped(sx as f64, sy as f64);
|
||||
let (c, sa) = unpremul(p);
|
||||
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
|
||||
let mut a = cov;
|
||||
|
||||
@@ -100,17 +100,11 @@ impl Cineon {
|
||||
fn knee(&self) -> f32 {
|
||||
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
|
||||
}
|
||||
/// Normalised code value (code / 1023) → linear.
|
||||
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
|
||||
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
|
||||
/// also uses with `1 / gamma`).
|
||||
pub fn log_to_lin(&self, v: f32) -> f32 {
|
||||
let off = self.offset();
|
||||
let code = v * 1023.0;
|
||||
let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
|
||||
lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
|
||||
let k = self.knee();
|
||||
if k < 1.0 && lin > k {
|
||||
let w = 1.0 - k;
|
||||
lin = k + w * (1.0 - (-(lin - k) / w).exp());
|
||||
}
|
||||
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
|
||||
self.ib + (self.iw - self.ib) * lin
|
||||
}
|
||||
/// Linear → normalised code value.
|
||||
@@ -495,6 +489,87 @@ mod tests {
|
||||
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
|
||||
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
|
||||
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
|
||||
/// linear light (code 400 is 0.10251).
|
||||
#[test]
|
||||
fn cineon_converter_matches_interpret_footage() {
|
||||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||||
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
|
||||
let out = run("ec.utility.cineon", &vals, img);
|
||||
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let footage = settings.convert(code / 1023.0);
|
||||
let effect = out.img.data[x][0];
|
||||
assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
|
||||
}
|
||||
}
|
||||
let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
|
||||
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// The Cineon Converter gives the same values as before Cineon Settings shared its
|
||||
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
|
||||
/// conversion types.
|
||||
#[test]
|
||||
fn cineon_converter_output_is_unchanged() {
|
||||
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
|
||||
let off = 10f32.powf((black - white) * 0.002 / 0.6);
|
||||
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
|
||||
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
|
||||
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
|
||||
if k < 1.0 && lin > k {
|
||||
let w = 1.0 - k;
|
||||
lin = k + w * (1.0 - (-(lin - k) / w).exp());
|
||||
}
|
||||
lin
|
||||
}
|
||||
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
|
||||
let off = 10f32.powf((black - white) * 0.002 / 0.6);
|
||||
let mut lin = v;
|
||||
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
|
||||
if k < 1.0 && lin > k {
|
||||
let w = 1.0 - k;
|
||||
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
|
||||
}
|
||||
lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
|
||||
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
|
||||
}
|
||||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||||
for gamma in [None, Some(2.4)] {
|
||||
for kind in 0..3 {
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let mut vals = vec![("conversionType", Value::Enum(kind))];
|
||||
if let Some(g) = gamma {
|
||||
vals.push(("gamma", num(g)));
|
||||
}
|
||||
let out = run("ec.utility.cineon", &vals, img);
|
||||
let g = gamma.unwrap_or(1.7) as f32;
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
let want = match kind {
|
||||
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
|
||||
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
|
||||
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
|
||||
};
|
||||
let got = out.img.data[x][0];
|
||||
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_compression_behaviour() {
|
||||
let mut img = Image::new(2, 1);
|
||||
|
||||
@@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[
|
||||
("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43),
|
||||
("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22),
|
||||
("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae),
|
||||
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d),
|
||||
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2),
|
||||
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce),
|
||||
("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067),
|
||||
("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc),
|
||||
("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641),
|
||||
("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284),
|
||||
("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4),
|
||||
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3),
|
||||
("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156),
|
||||
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a),
|
||||
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
|
||||
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
|
||||
("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2),
|
||||
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
|
||||
("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
|
||||
("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
|
||||
("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d),
|
||||
("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a),
|
||||
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618),
|
||||
("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3),
|
||||
("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557),
|
||||
|
||||
@@ -197,7 +197,8 @@ pub(crate) struct Work {
|
||||
}
|
||||
|
||||
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
|
||||
/// Footage at its native size is used straight from the footage source (no copy).
|
||||
/// Footage at its native size is used straight from the footage source (no copy unless its
|
||||
/// Cineon Settings convert it).
|
||||
pub(crate) fn source_frame(
|
||||
r: &Renderer,
|
||||
project: &Project,
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
|
||||
&& img.width == f.width
|
||||
&& img.height == f.height
|
||||
{
|
||||
return Some((img, [0.0; 2]));
|
||||
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
|
||||
}
|
||||
let buf = r.layer_source(&ctx, layer)?;
|
||||
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod animation;
|
||||
pub(crate) mod app_more;
|
||||
mod autotrace;
|
||||
mod batch;
|
||||
mod blender;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
pub(crate) mod comp_more;
|
||||
|
||||
@@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<ItemId> = proj.items.keys().copied().filter(|c| *c != comp).collect();
|
||||
for cid in ids {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in c.layers.iter_mut().filter(|l| is_user(l)) {
|
||||
for l in c.layers.iter_mut_where(|l| is_user(l)) {
|
||||
l.source = LayerSource::Comp { item: comp };
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Commands that can't run inside a batch (they replace the project or nest batches).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
|
||||
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
|
||||
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
|
||||
|
||||
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
|
||||
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
|
||||
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| step.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
|
||||
if NOT_IN_BATCH.contains(&id) {
|
||||
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
|
||||
}
|
||||
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Camera-solve interchange through Blender's public Python API. The generated script is
|
||||
//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod validate;
|
||||
|
||||
use super::{b_p, bad, f_p, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "camera.exportBlender";
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
const MAX_JSON: usize = 64 * 1024 * 1024;
|
||||
const MAX_FRAME: i64 = 1_048_574;
|
||||
|
||||
fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result<f64> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?,
|
||||
};
|
||||
if !v.is_finite() || !(min..=max).contains(&v) {
|
||||
return Err(bad(CMD, format!("{key}: expected {min}..{max}")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn finite(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) {
|
||||
return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up).
|
||||
fn position(p: [f64; 3], scale: f64) -> [f64; 3] {
|
||||
[p[0] * scale, p[2] * scale, -p[1] * scale]
|
||||
}
|
||||
|
||||
fn media_path(s: &Session, path: &str) -> Result<String> {
|
||||
if path.is_empty() || path.contains('\0') {
|
||||
return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting"));
|
||||
}
|
||||
let path = Path::new(path);
|
||||
if path.is_absolute() || cfg!(target_arch = "wasm32") {
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?;
|
||||
let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd);
|
||||
Ok(base.join(path).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
|
||||
if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files {
|
||||
return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on"));
|
||||
}
|
||||
let options = Options::parse(p)?;
|
||||
let Options { path, sensor, units, start, include_points } = options;
|
||||
if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) {
|
||||
return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?;
|
||||
let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?;
|
||||
for name in [ct::SOLVE, ct::TRACKS] {
|
||||
if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) {
|
||||
return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit"));
|
||||
}
|
||||
}
|
||||
let params = crate::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?;
|
||||
let (lo, end) = validate::timing(comp, layer, &solve, start)?;
|
||||
let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?;
|
||||
validate::geometry(&solve, sensor)?;
|
||||
let camera = params
|
||||
.get(ct::CAMERA)
|
||||
.map(|v| v.as_f64())
|
||||
.filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64)
|
||||
.and_then(|id| comp.layer(LayerId(id as u64)))
|
||||
.filter(|l| l.is_camera());
|
||||
let warnings = scene_warnings(camera, &solve, footage);
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) };
|
||||
let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?;
|
||||
let mut points = Vec::new();
|
||||
for point in &solve.points {
|
||||
finite(point.pos.into_iter().chain([point.error as f64]))?;
|
||||
if point.first > point.last || point.last as usize >= solve.frames.len() {
|
||||
return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker"));
|
||||
}
|
||||
let location = position(world.apply(point.pos), units);
|
||||
finite(location)?;
|
||||
if include_points {
|
||||
points.push(json!({"id": point.id, "location": location, "error": point.error,
|
||||
"first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)}));
|
||||
}
|
||||
}
|
||||
let tracks = ct::tracks(¶ms);
|
||||
if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) {
|
||||
return Err(bad(CMD, "2D tracks exceed export limits"));
|
||||
}
|
||||
let markers: Vec<Value> = comp
|
||||
.markers
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo)));
|
||||
if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None }
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name),
|
||||
"resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den,
|
||||
"startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units,
|
||||
"media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"},
|
||||
"fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode,
|
||||
"frameCount": source_count, "loopCount": footage.loop_count},
|
||||
"frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(),
|
||||
"groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase,
|
||||
"solve": solve, "tracks": if include_points { tracks.as_deref() } else { None },
|
||||
"warnings": warnings});
|
||||
let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?;
|
||||
if data.len() > MAX_JSON {
|
||||
return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot"));
|
||||
}
|
||||
let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?;
|
||||
let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed);
|
||||
s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
struct Options<'a> {
|
||||
path: &'a str,
|
||||
sensor: f64,
|
||||
units: f64,
|
||||
start: i64,
|
||||
include_points: bool,
|
||||
}
|
||||
|
||||
impl<'a> Options<'a> {
|
||||
fn parse(p: &'a Value) -> Result<Self> {
|
||||
let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?;
|
||||
if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) {
|
||||
return Err(bad(CMD, "the export path must end in .py"));
|
||||
}
|
||||
let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?;
|
||||
let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?;
|
||||
let start = match p.get("startFrame") {
|
||||
None => 1,
|
||||
Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?,
|
||||
};
|
||||
let include_points = match p.get("includePoints") {
|
||||
Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")),
|
||||
_ => b_p(p, "includePoints").unwrap_or(true),
|
||||
};
|
||||
Ok(Self { path, sensor, units, start, include_points })
|
||||
}
|
||||
}
|
||||
|
||||
struct Source<'a> {
|
||||
footage: &'a Footage,
|
||||
path: String,
|
||||
fps: f64,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result<Source<'a>> {
|
||||
let footage = match layer.source {
|
||||
LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind {
|
||||
ItemKind::Footage(f) => Some(f),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still));
|
||||
let footage = footage
|
||||
.ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?;
|
||||
let source_path = media_path(s, &footage.path)?;
|
||||
if Path::new(path) == Path::new(&source_path) {
|
||||
return Err(bad(CMD, "the export path must differ from the source footage"));
|
||||
}
|
||||
if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] {
|
||||
return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video"));
|
||||
}
|
||||
let source_fps = footage.frame_rate.as_f64();
|
||||
if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 {
|
||||
return Err(bad(CMD, "invalid source footage frame rate"));
|
||||
}
|
||||
// Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count,
|
||||
// clamp to the total loop range, then wrap. Native FPS does not change the frame index.
|
||||
let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128);
|
||||
let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor;
|
||||
let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count };
|
||||
if !(1..=MAX_FRAME as i128).contains(&source_count) {
|
||||
return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first"));
|
||||
}
|
||||
let source_count = source_count as i64;
|
||||
Ok(Source { footage, path: source_path, fps: source_fps, count: source_count })
|
||||
}
|
||||
|
||||
fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec<String> {
|
||||
let mut warnings = vec![
|
||||
"Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(),
|
||||
"The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(),
|
||||
];
|
||||
if camera.is_none() {
|
||||
warnings
|
||||
.push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into());
|
||||
} else {
|
||||
warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into());
|
||||
}
|
||||
if solve.distortion.is_some_and(|d| !d.is_none()) {
|
||||
warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into());
|
||||
}
|
||||
if (footage.pixel_aspect - 1.0).abs() > 1e-6 {
|
||||
warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Bake the solved camera and the linked camera's optical settings at exact composition ticks.
|
||||
fn bake_frames(
|
||||
ctx0: &EvalCtx<'_>,
|
||||
layer: &Layer,
|
||||
solve: &CameraSolve,
|
||||
camera: Option<&Layer>,
|
||||
options: &Options<'_>,
|
||||
footage: &Footage,
|
||||
source_count: i64,
|
||||
) -> Result<(Vec<Value>, linalg::Similarity)> {
|
||||
let comp = ctx0.comp;
|
||||
let lo = ctx0.time;
|
||||
let fd = comp.frame_duration();
|
||||
let sensor = options.sensor;
|
||||
let units = options.units;
|
||||
let start = options.start;
|
||||
let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?;
|
||||
let m = ctx0.world_matrix(layer).0;
|
||||
let layer_scale = m[0][0].hypot(m[1][0]);
|
||||
finite([layer_scale])?;
|
||||
if layer_scale <= 1e-9 {
|
||||
return Err(bad(CMD, "the tracked layer has zero scale"));
|
||||
}
|
||||
let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale);
|
||||
finite([world.s * units])?;
|
||||
let mut frames = Vec::with_capacity(solve.frames.len());
|
||||
for (i, frame) in solve.frames.iter().enumerate() {
|
||||
let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64)));
|
||||
let ctx = ctx0.at(time);
|
||||
let pose = linalg::mm(&world.r, &linalg::mt(&frame.r()));
|
||||
let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0);
|
||||
let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0);
|
||||
let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0);
|
||||
let location = position(world.apply(frame.center), units);
|
||||
let lens = frame.focal * sensor / solve.size[0];
|
||||
let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g)));
|
||||
let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback);
|
||||
let focus = value("focusDistance", world.s) * units;
|
||||
let aperture = value("aperture", 0.0);
|
||||
let zoom = value("zoom", frame.focal * layer_scale);
|
||||
// Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width,
|
||||
// and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter).
|
||||
let aperture_mm = aperture * 1920.0 / comp.width as f64;
|
||||
let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 };
|
||||
let rotation = value("irisRotation", 0.0).to_radians();
|
||||
let ratio = value("irisAspectRatio", 1.0);
|
||||
let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0);
|
||||
let blades = match shape {
|
||||
0 => 4,
|
||||
1..=8 => shape.saturating_add(2),
|
||||
_ => return Err(bad(CMD, "invalid iris shape")),
|
||||
};
|
||||
let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0;
|
||||
let source_time = ctx.source_time(layer);
|
||||
let source_frame = if footage.kind == FootageKind::Still {
|
||||
0
|
||||
} else {
|
||||
footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count
|
||||
};
|
||||
finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?;
|
||||
if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) {
|
||||
return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range"));
|
||||
}
|
||||
frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame,
|
||||
"location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof,
|
||||
"fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio,
|
||||
"sourceAperture": aperture, "sourceZoom": zoom,
|
||||
"blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)}));
|
||||
}
|
||||
Ok((frames, world))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Validate solved geometry and its correspondence to the current layer timing.
|
||||
|
||||
use effectcraft_project::{Comp, Layer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::{CameraSolve, linalg};
|
||||
|
||||
use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite};
|
||||
use crate::Result;
|
||||
|
||||
pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> {
|
||||
if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS {
|
||||
return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot"));
|
||||
}
|
||||
if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) {
|
||||
return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)"));
|
||||
}
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) {
|
||||
return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)"));
|
||||
}
|
||||
let end = start
|
||||
.checked_add(solve.frames.len() as i64)
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.filter(|n| *n <= MAX_FRAME)
|
||||
.ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?;
|
||||
finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?;
|
||||
if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 {
|
||||
return Err(bad(CMD, "invalid composition dimensions or zero layer stretch"));
|
||||
}
|
||||
let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO));
|
||||
let hi = layer.out_point.min(comp.duration);
|
||||
let fd = comp.frame_duration();
|
||||
if fd.0 <= 0 || lo >= hi {
|
||||
return Err(bad(CMD, "the tracked layer has no active frames"));
|
||||
}
|
||||
let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128;
|
||||
if expected != solve.frames.len() as u128 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker"));
|
||||
}
|
||||
let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds();
|
||||
if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 {
|
||||
return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker"));
|
||||
}
|
||||
Ok((lo, end))
|
||||
}
|
||||
|
||||
pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> {
|
||||
for frame in &solve.frames {
|
||||
finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?;
|
||||
if !frame.solved || frame.focal <= 0.0 {
|
||||
return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker"));
|
||||
}
|
||||
let mm = sensor * frame.focal / solve.size[0];
|
||||
if !(1.0..=5000.0).contains(&mm) {
|
||||
return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth"));
|
||||
}
|
||||
}
|
||||
if let Some(ground) = &solve.ground {
|
||||
finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?;
|
||||
if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4
|
||||
|| (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4
|
||||
|| linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4
|
||||
{
|
||||
return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again"));
|
||||
}
|
||||
}
|
||||
if let Some(d) = &solve.distortion {
|
||||
finite([d.k1, d.k2, d.radius])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0.
|
||||
# Run in Blender's Scripting workspace (Open, Run Script), or:
|
||||
# blender --background --python "Tracked Scene.py"
|
||||
# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself.
|
||||
# Public API types: bpy.types.Camera, bpy.types.ImageUser
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
DATA = json.loads(__EFFECTCRAFT_DATA__)
|
||||
|
||||
|
||||
def _curves(owner):
|
||||
"""Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions."""
|
||||
anim = owner.animation_data
|
||||
if anim is None or anim.action is None:
|
||||
return
|
||||
action = anim.action
|
||||
if getattr(action, "is_action_layered", False):
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
if strip.type == 'KEYFRAME':
|
||||
bag = strip.channelbag(anim.action_slot)
|
||||
if bag is not None:
|
||||
yield from bag.fcurves
|
||||
else:
|
||||
yield from action.fcurves
|
||||
|
||||
|
||||
def _interpolation(owner):
|
||||
for curve in _curves(owner):
|
||||
# Footage indices and discrete switches must never interpolate between frames.
|
||||
stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path
|
||||
or "aperture_blades" in curve.data_path)
|
||||
for key in curve.keyframe_points:
|
||||
key.interpolation = 'CONSTANT' if stepped else 'LINEAR'
|
||||
|
||||
|
||||
def _media_path():
|
||||
original = DATA["media"]["path"]
|
||||
candidates = [original]
|
||||
# When sharing, place the video alongside the script or the .blend and run again.
|
||||
script_path = globals().get("__file__", "")
|
||||
if script_path:
|
||||
candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)),
|
||||
os.path.basename(original)))
|
||||
if bpy.data.filepath:
|
||||
candidates.append(os.path.join(os.path.dirname(bpy.data.filepath),
|
||||
os.path.basename(original)))
|
||||
return next((p for p in candidates if os.path.isfile(p)), None)
|
||||
|
||||
|
||||
def import_scene():
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer")
|
||||
scene = bpy.data.scenes.new(DATA["name"])
|
||||
scene.frame_start = DATA["startFrame"]
|
||||
scene.frame_end = DATA["endFrame"]
|
||||
fps = DATA["fpsNumerator"] / DATA["fpsDenominator"]
|
||||
scene.render.fps = max(1, round(fps))
|
||||
scene.render.fps_base = scene.render.fps / fps
|
||||
scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"])
|
||||
scene.render.resolution_percentage = 100
|
||||
# The tracker assumes square pixels, regardless of the source's interpretation metadata.
|
||||
scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0
|
||||
scene.render.film_transparent = True
|
||||
scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"]))
|
||||
scene.unit_settings.system = 'METRIC'
|
||||
scene.unit_settings.scale_length = 1.0
|
||||
scene["effectcraft_unit_scale"] = DATA["unitScale"]
|
||||
scene["effectcraft_scale_is_arbitrary"] = True
|
||||
scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"]
|
||||
|
||||
collection = bpy.data.collections.new("EffectCraft Tracking")
|
||||
scene.collection.children.link(collection)
|
||||
camera_data = bpy.data.cameras.new("3D Tracker Camera")
|
||||
camera = bpy.data.objects.new("3D Tracker Camera", camera_data)
|
||||
collection.objects.link(camera)
|
||||
scene.camera = camera
|
||||
camera.rotation_mode = 'QUATERNION'
|
||||
camera_data.type = 'PERSP'
|
||||
camera_data.sensor_fit = 'HORIZONTAL'
|
||||
camera_data.sensor_width = DATA["sensorWidth"]
|
||||
camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1]
|
||||
/ DATA["resolution"][0])
|
||||
camera_data.show_background_images = True
|
||||
camera_data.show_limits = True
|
||||
extent = max([DATA["groundSize"], 1.0]
|
||||
+ [math.dist(p["location"], f["location"])
|
||||
for p in DATA["points"][::max(1, len(DATA["points"]) // 100)]
|
||||
for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]])
|
||||
camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0))
|
||||
camera_data.clip_end = max(1000.0, extent * 10.0)
|
||||
|
||||
previous = None
|
||||
for sample in DATA["frames"]:
|
||||
frame = sample["frame"]
|
||||
camera.location = sample["location"]
|
||||
# Exported right/up/back vectors are COLUMNS of Blender's local → world basis.
|
||||
quaternion = Matrix(sample["rotation"]).transposed().to_quaternion()
|
||||
if previous is not None and previous.dot(quaternion) < 0:
|
||||
quaternion.negate()
|
||||
camera.rotation_quaternion = quaternion
|
||||
previous = quaternion.copy()
|
||||
camera.keyframe_insert(data_path="location", frame=frame)
|
||||
camera.keyframe_insert(data_path="rotation_quaternion", frame=frame)
|
||||
camera_data.lens = sample["lens"]
|
||||
camera_data.keyframe_insert(data_path="lens", frame=frame)
|
||||
camera_data.dof.use_dof = sample["dof"]
|
||||
camera_data.dof.focus_distance = sample["focus"]
|
||||
camera_data.dof.aperture_fstop = sample["fstop"]
|
||||
camera_data.dof.aperture_blades = sample["blades"]
|
||||
camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi
|
||||
camera_data.dof.aperture_ratio = sample["irisRatio"]
|
||||
for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades",
|
||||
"aperture_rotation", "aperture_ratio"):
|
||||
camera_data.keyframe_insert(data_path="dof." + prop, frame=frame)
|
||||
|
||||
path = _media_path()
|
||||
if path is not None:
|
||||
try:
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
image.source = DATA["media"]["kind"]
|
||||
background = camera_data.background_images.new()
|
||||
background.source = 'IMAGE'
|
||||
background.image = image
|
||||
background.alpha = 1.0
|
||||
background.display_depth = 'BACK'
|
||||
background.frame_method = 'STRETCH'
|
||||
user = background.image_user
|
||||
user.use_auto_refresh = True
|
||||
user.use_cyclic = False
|
||||
user.frame_start = DATA["startFrame"]
|
||||
user.frame_duration = max(1, min(1048574, image.frame_duration))
|
||||
if image.source == 'MOVIE':
|
||||
for sample in DATA["frames"]:
|
||||
# Blender uses 1-based image frames; sourceFrame is 0-based.
|
||||
user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start)
|
||||
camera_data.keyframe_insert(
|
||||
data_path="background_images[0].image_user.frame_offset",
|
||||
frame=sample["frame"])
|
||||
except (RuntimeError, ValueError, OSError) as error:
|
||||
DATA["warnings"].append("Blender could not load the background video: " + str(error))
|
||||
else:
|
||||
DATA["warnings"].append("Video not found: " + DATA["media"]["path"]
|
||||
+ ". Place it beside this script or relink the camera background.")
|
||||
|
||||
if DATA["points"]:
|
||||
mesh = bpy.data.meshes.new("Solved Track Points")
|
||||
mesh.from_pydata([p["location"] for p in DATA["points"]], [], [])
|
||||
mesh.update()
|
||||
cloud = bpy.data.objects.new("Solved Track Points", mesh)
|
||||
collection.objects.link(cloud)
|
||||
cloud.show_in_front = True
|
||||
cloud.hide_render = True
|
||||
for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"),
|
||||
("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")):
|
||||
attr = mesh.attributes.new(name=name, type=kind, domain='POINT')
|
||||
for dest, point in zip(attr.data, DATA["points"]):
|
||||
# Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too.
|
||||
dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key])
|
||||
|
||||
if DATA["ground"]:
|
||||
size = DATA["groundSize"]
|
||||
mesh = bpy.data.meshes.new("Solved Ground Plane")
|
||||
mesh.from_pydata([(-size, -size, 0), (size, -size, 0),
|
||||
(size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)])
|
||||
mesh.update()
|
||||
ground = bpy.data.objects.new("Solved Ground Plane", mesh)
|
||||
collection.objects.link(ground)
|
||||
ground.display_type = 'WIRE'
|
||||
ground.hide_render = True
|
||||
for marker in DATA["markers"]:
|
||||
scene.timeline_markers.new(marker["name"], frame=marker["frame"])
|
||||
|
||||
_interpolation(camera)
|
||||
_interpolation(camera_data)
|
||||
metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json")
|
||||
metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2))
|
||||
scene["effectcraft_metadata_text"] = metadata.name
|
||||
camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"]
|
||||
camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"]
|
||||
camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name
|
||||
distortion = DATA["solve"].get("distortion")
|
||||
if distortion:
|
||||
for key in ("k1", "k2", "radius"):
|
||||
camera_data["effectcraft_distortion_" + key] = distortion[key]
|
||||
if bpy.context.window is not None:
|
||||
bpy.context.window.scene = scene
|
||||
for area in bpy.context.window.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
area.spaces.active.region_3d.view_perspective = 'CAMERA'
|
||||
scene.frame_set(scene.frame_start)
|
||||
for warning in DATA["warnings"]:
|
||||
print("EffectCraft: " + warning)
|
||||
print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name)
|
||||
return scene
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
EFFECTCRAFT_SCENE = import_scene()
|
||||
@@ -42,11 +42,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
create_from_solve
|
||||
),
|
||||
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
|
||||
cmd!(
|
||||
"camera.exportBlender",
|
||||
"Export Scene to Blender...",
|
||||
[],
|
||||
None,
|
||||
"{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}",
|
||||
has_comp,
|
||||
export_blender
|
||||
),
|
||||
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
|
||||
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
|
||||
]
|
||||
}
|
||||
|
||||
fn export_blender(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?;
|
||||
super::blender::export(s, p, cid, lid, uid)
|
||||
}
|
||||
|
||||
fn with_kind(p: &Value, kind: &str) -> Value {
|
||||
let mut q = p.clone();
|
||||
if !q.is_object() {
|
||||
|
||||
@@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0;
|
||||
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
|
||||
if let Some(tr) = l.transform_mut() {
|
||||
super::layer::offset_position(tr, dx, dy);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &
|
||||
let (old_w, old_h) = (c.width, c.height);
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
for l in c.layers.iter_mut_where(|l| l.parent.is_none()) {
|
||||
if let Some(tr) = l.transform_mut() {
|
||||
super::layer::offset_position(tr, -x, -y);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) {
|
||||
let present: Vec<LayerId> = comp.layers.iter().map(|l| l.id).collect();
|
||||
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id));
|
||||
for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) {
|
||||
l.parent = l.parent.and_then(link);
|
||||
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
|
||||
}
|
||||
@@ -685,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.label = lab;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's
|
||||
/// New Composition From Footage) they become one new composition instead ([`new_comp`]).
|
||||
///
|
||||
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
|
||||
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
|
||||
@@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
/// After an import (its result `r`): with `newComp`, the new items become one new composition;
|
||||
/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
let errors = add_to_comp(s, r, target, p);
|
||||
let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) };
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
@@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Val
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let add = imported(s, r);
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
@@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
|
||||
errors
|
||||
}
|
||||
|
||||
/// What an import (its result `r`) made that belongs in a comp: the comps of layered files
|
||||
/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items,
|
||||
/// without those comps again or the files' folders.
|
||||
fn imported(s: &Session, r: &Value) -> Vec<u64> {
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let mut in_comps = std::collections::HashSet::new();
|
||||
let mut todo = comps.clone();
|
||||
while let Some(c) = todo.pop() {
|
||||
for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) {
|
||||
match l.source {
|
||||
LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0),
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } => {
|
||||
in_comps.insert(item.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder()));
|
||||
comps.iter().copied().chain(rest).collect()
|
||||
}
|
||||
|
||||
/// With `newComp`, make one composition of the footage and solids an import (its result `r`)
|
||||
/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp
|
||||
/// the import made (a layered file) already is the composition, so it stays out, as does data
|
||||
/// footage; none left, no new comp. Returns what went wrong.
|
||||
fn new_comp(s: &mut Session, r: &Value) -> Vec<String> {
|
||||
let items: Vec<ItemId> = imported(s, r)
|
||||
.into_iter()
|
||||
.map(ItemId)
|
||||
.filter(|i| {
|
||||
s.project.item(*i).is_some_and(|it| match &it.kind {
|
||||
ItemKind::Footage(f) => f.kind != FootageKind::Data,
|
||||
ItemKind::Solid(_) => true,
|
||||
ItemKind::Comp(_) | ItemKind::Folder => false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
s.state.project_selection = items;
|
||||
match s.execute("file.newCompFromSelection", json!({"single": true})) {
|
||||
Ok(_) => vec![],
|
||||
Err(e) => vec![e.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
@@ -746,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
|
||||
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
|
||||
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
|
||||
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
|
||||
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
|
||||
report_js(s, name, out)
|
||||
}
|
||||
|
||||
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
|
||||
/// reported under its file name `name` (Startup and Shutdown scripts).
|
||||
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
|
||||
let out = run_js(s, code, path, false)?;
|
||||
report_js(s, name, out)
|
||||
}
|
||||
|
||||
/// Run JavaScript through the session's scripting engine.
|
||||
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
|
||||
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
|
||||
@@ -594,6 +601,131 @@ pub(crate) struct Interpretation {
|
||||
preserve_rgb: Option<bool>,
|
||||
/// Start Timecode.
|
||||
start: Option<StartTimecode>,
|
||||
/// Cineon Settings (DPX and Cineon footage).
|
||||
cineon: CineonEdit,
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CineonEdit {
|
||||
/// Logarithmic Conversion.
|
||||
log: Option<bool>,
|
||||
units: Option<CineonUnits>,
|
||||
/// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,
|
||||
/// else the footage's own Units).
|
||||
point_units: Option<CineonUnits>,
|
||||
converted_black: Option<f64>,
|
||||
converted_white: Option<f64>,
|
||||
/// 10 Bit Black and White Points.
|
||||
black: Option<f64>,
|
||||
white: Option<f64>,
|
||||
gamma: Option<f64>,
|
||||
rolloff: Option<f64>,
|
||||
}
|
||||
|
||||
impl CineonEdit {
|
||||
fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {
|
||||
let units = |key: &str| -> Result<Option<CineonUnits>> {
|
||||
match str_p(p, key) {
|
||||
None => Ok(None),
|
||||
Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))),
|
||||
}
|
||||
};
|
||||
let num = |key: &str, lo: f64, hi: f64| -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && (lo..=hi).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))),
|
||||
}
|
||||
};
|
||||
let units_now = units("cineonUnits")?;
|
||||
Ok(CineonEdit {
|
||||
log: b_p(p, "logConversion"),
|
||||
units: units_now,
|
||||
point_units: units("pointUnits")?.or(units_now),
|
||||
// Range-checked once their units are known (`settings_for`).
|
||||
converted_black: num("convertedBlack", f64::MIN, f64::MAX)?,
|
||||
converted_white: num("convertedWhite", f64::MIN, f64::MAX)?,
|
||||
black: num("tenBitBlack", 0.0, 1023.0)?,
|
||||
white: num("tenBitWhite", 0.0, 1023.0)?,
|
||||
gamma: num("currentGamma", 0.1, 10.0)?,
|
||||
rolloff: num("highlightRolloff", 0.0, 100.0)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.log.is_none()
|
||||
&& self.units.is_none()
|
||||
&& self.converted_black.is_none()
|
||||
&& self.converted_white.is_none()
|
||||
&& self.black.is_none()
|
||||
&& self.white.is_none()
|
||||
&& self.gamma.is_none()
|
||||
&& self.rolloff.is_none()
|
||||
}
|
||||
|
||||
/// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon
|
||||
/// parameters, or `f` is not a DPX or Cineon file).
|
||||
fn settings_for(&self, f: &Footage, cmd: &str) -> Result<Option<CineonSettings>> {
|
||||
if self.is_empty() || !f.is_cineon_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut c = f.cineon;
|
||||
let units = self.point_units.unwrap_or(c.units);
|
||||
let point = |v: f64| -> Result<f32> {
|
||||
let ten = units.to_ten_bit(v);
|
||||
let max = units.from_ten_bit(f64::from(CineonSettings::MAX));
|
||||
if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) {
|
||||
Ok(ten as f32)
|
||||
} else {
|
||||
Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label())))
|
||||
}
|
||||
};
|
||||
if let Some(v) = self.converted_black {
|
||||
c.converted_black = point(v)?;
|
||||
}
|
||||
if let Some(v) = self.converted_white {
|
||||
c.converted_white = point(v)?;
|
||||
}
|
||||
if let Some(v) = self.log {
|
||||
c.log_conversion = v;
|
||||
}
|
||||
if let Some(u) = self.units {
|
||||
c.units = u;
|
||||
}
|
||||
if let Some(v) = self.black {
|
||||
c.black = v as f32;
|
||||
}
|
||||
if let Some(v) = self.white {
|
||||
c.white = v as f32;
|
||||
}
|
||||
if let Some(v) = self.gamma {
|
||||
c.gamma = v as f32;
|
||||
}
|
||||
if let Some(v) = self.rolloff {
|
||||
c.rolloff = v as f32;
|
||||
}
|
||||
if c.black >= c.white {
|
||||
return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point"));
|
||||
}
|
||||
Ok(Some(c))
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to).
|
||||
fn cineon_json(f: &Footage) -> Value {
|
||||
if !f.is_cineon_file() {
|
||||
return Value::Null;
|
||||
}
|
||||
let c = &f.cineon;
|
||||
json!({
|
||||
"logConversion": c.log_conversion, "units": c.units.id(),
|
||||
"convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)),
|
||||
"tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Start Timecode.
|
||||
@@ -687,9 +819,23 @@ impl Interpretation {
|
||||
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
|
||||
)),
|
||||
};
|
||||
it.cineon = CineonEdit::parse(p, cmd)?;
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
/// Check the Cineon Settings this interpretation gives each of `footage`: an error names a
|
||||
/// value out of range, or Cineon parameters when none of the footage is DPX or Cineon.
|
||||
pub(crate) fn check<'a>(&self, footage: impl IntoIterator<Item = &'a Footage>, cmd: &str) -> Result<()> {
|
||||
if self.cineon.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut any = false;
|
||||
for f in footage {
|
||||
any |= self.cineon.settings_for(f, cmd)?.is_some();
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) }
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
let before = f.frame_rate;
|
||||
self.apply_settings(f, guessed);
|
||||
@@ -749,6 +895,10 @@ impl Interpretation {
|
||||
if let Some(v) = self.preserve_rgb {
|
||||
f.preserve_rgb = v;
|
||||
}
|
||||
// Checked before the edit (`Interpretation::check`).
|
||||
if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") {
|
||||
f.cineon = c;
|
||||
}
|
||||
// After the frame rate: timecode is read at the footage's rate.
|
||||
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
|
||||
f.start_timecode = start;
|
||||
@@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let it = Interpretation::parse(p, "file.interpretFootage")?;
|
||||
let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => Some(f),
|
||||
_ => None,
|
||||
});
|
||||
it.check(footage, "file.interpretFootage")?;
|
||||
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
|
||||
.iter()
|
||||
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
|
||||
@@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
|
||||
"frames": f.sequence_frames(),
|
||||
"frames": f.sequence_frames(), "cineon": cineon_json(f),
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
@@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.linear_light = src.linear_light;
|
||||
f.preserve_rgb = src.preserve_rgb;
|
||||
f.color_profile = src.color_profile;
|
||||
if f.is_cineon_file() && src.is_cineon_file() {
|
||||
f.cineon = src.cineon;
|
||||
}
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
@@ -1090,7 +1248,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
|
||||
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings
|
||||
/// applied.
|
||||
pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
|
||||
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
|
||||
}
|
||||
|
||||
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
|
||||
let (_, fr) = source_timing(s, item);
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
@@ -207,10 +213,7 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let item = s.project.item(v.item);
|
||||
let name = item.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let frame = fr.frame_at(v.time);
|
||||
let problem = match item.map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => s.footage.problem(f),
|
||||
_ => None,
|
||||
};
|
||||
let problem = item.and_then(|i| s.footage.item_problem(i));
|
||||
Ok(
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
|
||||
)
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
||||
@@ -526,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
|
||||
s.edit("Parent", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.parent = parent;
|
||||
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
|
||||
f.apply(l);
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
|
||||
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
|
||||
let target = v.unwrap_or(!first);
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
set_switch_value(l, &name, target);
|
||||
last = target;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
alone &= on == (ids.contains(&l.id) == active);
|
||||
}
|
||||
let others = value.unwrap_or(alone == active);
|
||||
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
|
||||
for l in comp.layers.iter_mut_where(|l| applies(l)) {
|
||||
let v = if ids.contains(&l.id) { active } else { others };
|
||||
set_switch_value(l, name, v);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
|
||||
s.edit("Layer Comment", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.comment = text.clone();
|
||||
}
|
||||
Ok(())
|
||||
@@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Blending Mode", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.blend_mode = match mode {
|
||||
Some(m) => m,
|
||||
None if step > 0 => l.blend_mode.next(),
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
let comp_dur = comp.duration;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let shift = |l: &mut Layer, d: Tick| {
|
||||
l.start_time += d;
|
||||
l.in_point += d;
|
||||
@@ -161,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in 1..n {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
|
||||
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
|
||||
comp.layers[i - 1..run_end].rotate_left(1);
|
||||
comp.layers.rotate_range_left(i - 1..run_end, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..n.saturating_sub(1)).rev() {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
|
||||
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
|
||||
comp.layers[run_start..=i + 1].rotate_right(1);
|
||||
comp.layers.rotate_range_right(run_start..=i + 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))),
|
||||
};
|
||||
s.edit("Layer Quality", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.quality = q;
|
||||
}
|
||||
Ok(())
|
||||
@@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q => q,
|
||||
};
|
||||
s.edit("Layer Sampling", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.sampling = q;
|
||||
}
|
||||
Ok(())
|
||||
@@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))),
|
||||
};
|
||||
s.edit("Frame Blending", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.switches.frame_blend = m;
|
||||
}
|
||||
Ok(())
|
||||
@@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
|
||||
// eye cell shows): the selection stays shown.
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
Ok(())
|
||||
@@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// Track mattes stay hidden (their video switch is off by design).
|
||||
let mattes: Vec<LayerId> = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
|
||||
for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) {
|
||||
for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) {
|
||||
l.switches.video = true;
|
||||
}
|
||||
Ok(())
|
||||
@@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = b_p(p, "enabled").unwrap_or(true);
|
||||
let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.props.walk_mut(&mut |pr| {
|
||||
if let Some(e) = &mut pr.expr {
|
||||
e.enabled = on;
|
||||
@@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = s.edit("Lock Markers", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked));
|
||||
for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
l.markers_locked = target;
|
||||
}
|
||||
Ok(target)
|
||||
@@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Delete All Markers", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) {
|
||||
l.markers.clear();
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fd = comp_rate.frame_duration();
|
||||
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let old = l.stretch;
|
||||
if reverse {
|
||||
// Keep the in/out points; play the visible span backwards.
|
||||
@@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut on = false;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) {
|
||||
let has = l.props.get("timeRemap").is_some();
|
||||
let target = want.unwrap_or(!has);
|
||||
on |= target;
|
||||
|
||||
@@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
|
||||
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
|
||||
}
|
||||
if r == Renderer::Classic3D {
|
||||
let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment });
|
||||
if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) {
|
||||
s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer");
|
||||
}
|
||||
}
|
||||
Ok(json!({"renderer": renderer_id(r)}))
|
||||
}
|
||||
|
||||
@@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str {
|
||||
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
|
||||
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
|
||||
/// the renderer changed.
|
||||
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
|
||||
pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let switched = c.renderer != Renderer::Advanced3D;
|
||||
c.renderer = Renderer::Advanced3D;
|
||||
@@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu
|
||||
}
|
||||
|
||||
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
|
||||
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
|
||||
pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) {
|
||||
if switched {
|
||||
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
|
||||
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
|
||||
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = insert_layer_at(proj, st, cid, l, index)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
report_switch(s, cid, switched, "which draws 3D models");
|
||||
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
|
||||
}
|
||||
|
||||
@@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
report_switch(s, cid, switched, "which draws 3D models");
|
||||
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use effectcraft_keyframe::{ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer_menu::target_masks;
|
||||
@@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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());
|
||||
/// The path a parametric shape group draws, in the group's own space. This is the geometry the
|
||||
/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the
|
||||
/// converted path is the shape that was on screen rather than an approximation of it; the defaults
|
||||
/// match that code for a property a group doesn't carry (a polygon has no inner radius or
|
||||
/// roundness).
|
||||
fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| ctx.f(layer, g, id, d);
|
||||
let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d);
|
||||
let path = match g.match_id.as_str() {
|
||||
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
|
||||
"rect" => {
|
||||
let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2]));
|
||||
if ctx.b(layer, g, "perCorner") {
|
||||
effectcraft_path::rect_radii(
|
||||
size,
|
||||
pos,
|
||||
[f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)],
|
||||
)
|
||||
} else {
|
||||
effectcraft_path::rect(size, pos, f("roundness", 0.0))
|
||||
}
|
||||
}
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
star: ctx.e(layer, g, "type") == 0,
|
||||
points: f("points", 5.0),
|
||||
pos: v2("position", [0.0; 2]),
|
||||
rotation: f("rotation", 0.0),
|
||||
@@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
// Read the geometry before editing, with the same expression-aware context the renderer
|
||||
// uses: an expression-driven corner radius, size or position must convert to what is on
|
||||
// screen, not to the property's underlying value.
|
||||
let prepared = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut prepared = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue };
|
||||
let Some(path) = parametric_path(&ctx, l, old) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled));
|
||||
}
|
||||
prepared
|
||||
};
|
||||
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut made = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for (lid, uid, path, direction, enabled) in prepared {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
|
||||
let Some(old) = contents.find_group(*uid) else { continue };
|
||||
let Some(path) = parametric_path(old, lt) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
|
||||
let enabled = old.enabled;
|
||||
let mut g = build::shape_path(&mut Ids(&mut next), path);
|
||||
if let Some(pr) = g.prop_mut("direction") {
|
||||
pr.value = KV::Enum(direction);
|
||||
}
|
||||
g.enabled = enabled;
|
||||
let new_uid = g.uid;
|
||||
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
|
||||
let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
made.push((lid, new_uid));
|
||||
}
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
@@ -360,7 +383,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
const SHAPE_ITEMS: &[&str] = &[
|
||||
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
|
||||
"wiggle", "merge", "front", "bevel", "side", "back",
|
||||
"wiggle", "wiggler", "merge", "front", "bevel", "side", "back",
|
||||
];
|
||||
|
||||
/// Selected shape items (contents entries) of shape layers: (layer, uid).
|
||||
|
||||
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let muted = b_p(p, "muted").unwrap_or(true);
|
||||
let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" };
|
||||
edit_keys(s, label, None, move |keys, i, _| {
|
||||
keys[i].muted = muted;
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// A number or a per-dimension array of numbers.
|
||||
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
|
||||
match p.get(k)? {
|
||||
@@ -1381,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&json!({"which": "out"})
|
||||
)),
|
||||
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
|
||||
cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted),
|
||||
cmd!(
|
||||
"keys.interpolation",
|
||||
"Keyframe Interpolation...",
|
||||
|
||||
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
@@ -229,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
|
||||
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
|
||||
@@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
o["layers"] = json!(c.layers.len());
|
||||
o["frameRate"] = json!(c.frame_rate.as_f64());
|
||||
}
|
||||
// Footage that shows no picture although its file is there (a codec or profile the
|
||||
// decoders lack): why, so an agent can tell the user.
|
||||
if let ItemKind::Footage(f) = &i.kind
|
||||
&& let Some(why) = s.footage.problem(f)
|
||||
{
|
||||
// Footage (or the proxy in use) that shows no picture although its file is there (a
|
||||
// codec or profile the decoders lack): why, so an agent can tell the user.
|
||||
if let Some(why) = s.footage.item_problem(i) {
|
||||
o["problem"] = json!(why);
|
||||
}
|
||||
o
|
||||
|
||||
@@ -277,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_expr(&mut cam, "cameraOptions/zoom", ze);
|
||||
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
|
||||
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
|
||||
comp.layers = vec![cam, nest];
|
||||
comp.layers = vec![cam, nest].into();
|
||||
eye_comps.push(eid);
|
||||
}
|
||||
// The output comp: both eyes through 3D Glasses.
|
||||
@@ -300,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
let (lid, rid) = (left.id, right.id);
|
||||
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
|
||||
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into();
|
||||
st.project_selection = vec![oid];
|
||||
Ok(json!({
|
||||
"controls": ctl_id.0,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
|
||||
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
|
||||
//! quits, after the project is closed (`file.runShutdownScripts`).
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
|
||||
pub const STARTUP_DIR: &str = "Scripts/Startup";
|
||||
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
|
||||
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
|
||||
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
|
||||
pub const RUNS_SCRIPTS: &[&str] =
|
||||
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
|
||||
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
|
||||
/// `$.evalFile` allow.
|
||||
const MAX_SCRIPT_BYTES: usize = 16 << 20;
|
||||
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
|
||||
const MAX_SHUTDOWN_DIALOGS: usize = 64;
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
@@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
|
||||
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
|
||||
let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR };
|
||||
cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?;
|
||||
s.scripts_changed();
|
||||
// After Effects asks for a restart; our menus pick it up at once.
|
||||
s.toast(if panel {
|
||||
format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu")
|
||||
@@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
s.scripts_changed();
|
||||
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
|
||||
}
|
||||
|
||||
@@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
|
||||
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
|
||||
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
|
||||
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
|
||||
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
|
||||
/// if canceled (`show()` returns 2) and the script runs on.
|
||||
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
|
||||
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
|
||||
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
|
||||
for name in names {
|
||||
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
|
||||
Some(Ok(Some(code))) => code,
|
||||
read => {
|
||||
let why = match read {
|
||||
Some(Err(e)) => format!(": {e}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let error = format!("{name}: cannot read the script{why}");
|
||||
log::warn!("{dir}: {error}");
|
||||
s.events.push(Event::Toast { message: error.clone(), error: true });
|
||||
failed.push(json!({"name": name, "error": error}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
|
||||
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
|
||||
// and `$.evalFile` find the files beside it.
|
||||
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
|
||||
// Windows open before this script runs belong to other scripts, perhaps of the same name.
|
||||
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
|
||||
let hosts_before = s.script_ui.task_hosts.clone();
|
||||
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
|
||||
if !quitting {
|
||||
// The Startup script's context: the one that scheduled tasks or holds the windows it
|
||||
// opened (a task scheduled after its dialog closes runs there too).
|
||||
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
|
||||
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
|
||||
let hosts: Vec<u32> = tasks.chain(windows).collect();
|
||||
for h in hosts {
|
||||
if !s.script_ui.startup_hosts.contains(&h) {
|
||||
s.script_ui.startup_hosts.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut closed = 0;
|
||||
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
|
||||
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
|
||||
// report_js reports an error the script throws after its dialog closes, as it does for an
|
||||
// error in the script's first run.
|
||||
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
|
||||
closed += 1;
|
||||
}
|
||||
match r {
|
||||
Ok(_) if is_waiting(&r) => waiting.push(name),
|
||||
Ok(_) => ran.push(name),
|
||||
Err(e) => {
|
||||
// Also in the log: no frame draws a Shutdown script's toast.
|
||||
log::warn!("{dir}: {e}");
|
||||
failed.push(json!({"name": name, "error": e.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"ran": ran, "waiting": waiting, "failed": failed})
|
||||
}
|
||||
|
||||
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
|
||||
/// launch, before a project opens).
|
||||
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(run_folder(s, STARTUP_DIR, false))
|
||||
}
|
||||
|
||||
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
|
||||
/// (frontends call it as the app quits, after the unsaved-changes prompt).
|
||||
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.replace_project(effectcraft_project::Project::default(), None);
|
||||
Ok(run_folder(s, SHUTDOWN_DIR, true))
|
||||
}
|
||||
|
||||
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
@@ -414,6 +510,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
restore_panels
|
||||
),
|
||||
cmd!(
|
||||
"file.runStartupScripts",
|
||||
"Run Startup Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
|
||||
always,
|
||||
run_startup
|
||||
),
|
||||
cmd!(
|
||||
"file.runShutdownScripts",
|
||||
"Run Shutdown Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
|
||||
always,
|
||||
run_shutdown
|
||||
),
|
||||
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
|
||||
cmd!(
|
||||
"script.settings.save",
|
||||
@@ -463,10 +577,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Mouse Event on Script Window Control",
|
||||
[],
|
||||
None,
|
||||
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
|
||||
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
|
||||
always,
|
||||
scriptui::mouse
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.resize",
|
||||
"Resize Script Window",
|
||||
[],
|
||||
None,
|
||||
"{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)",
|
||||
always,
|
||||
scriptui::resize
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.close",
|
||||
"Close Script Window",
|
||||
@@ -948,4 +1071,64 @@ mod tests {
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels),
|
||||
/// as soon as one is installed and until it is uninstalled; a key given to one is saved with
|
||||
/// the preset.
|
||||
#[test]
|
||||
fn file_scripts_entries_take_keyboard_shortcuts() {
|
||||
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key};
|
||||
let (mut s, root) = session_in_tmp("shortcuts");
|
||||
let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx"));
|
||||
std::fs::write(&script, "hello").unwrap();
|
||||
std::fs::write(&panel, "panel").unwrap();
|
||||
let listed = |s: &Session, name: &str| {
|
||||
s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone()))
|
||||
};
|
||||
assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples");
|
||||
assert_eq!(listed(&s, "Hello.jsx"), None);
|
||||
s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap();
|
||||
s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap();
|
||||
assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![])));
|
||||
assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
|
||||
let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"}));
|
||||
assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}");
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7"));
|
||||
let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap());
|
||||
assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()]));
|
||||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert_eq!(listed(&s, "Hello.jsx"), None);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand
|
||||
/// after the shortcut table was built; a script the folder doesn't hold (never there, or
|
||||
/// removed by hand) can't take a key, and Run Script File… keeps its own keys.
|
||||
#[test]
|
||||
fn shortcut_commands_find_scripts_copied_into_the_folder() {
|
||||
let (mut s, root) = session_in_tmp("copied");
|
||||
assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built");
|
||||
std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap();
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap();
|
||||
let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap();
|
||||
assert_eq!(list["count"], 1, "{list}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap();
|
||||
assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
// The binding-key form finds a script copied in since the last lookup, too.
|
||||
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap();
|
||||
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap();
|
||||
assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}");
|
||||
// Removed by hand: no longer in the folder.
|
||||
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap();
|
||||
std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap();
|
||||
let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- shortcuts
|
||||
|
||||
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
|
||||
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
|
||||
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A
|
||||
/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it
|
||||
/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error,
|
||||
/// not Run Script File….
|
||||
fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
|
||||
let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?;
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let t = s.shortcuts();
|
||||
let key = binding_key(c, ¶ms);
|
||||
t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
|
||||
let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {");
|
||||
if script {
|
||||
s.scripts_changed();
|
||||
}
|
||||
let t = s.shortcuts();
|
||||
let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) };
|
||||
found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)")))
|
||||
}
|
||||
|
||||
fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Scripts copied into the Scripts folder since the table was built are listed too.
|
||||
s.scripts_changed();
|
||||
let q = str_p(p, "query").unwrap_or("").to_lowercase();
|
||||
let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false);
|
||||
let keys_q = str_p(p, "keys").and_then(normalize);
|
||||
|
||||
@@ -12,6 +12,7 @@ use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::model3d::{report_switch, use_advanced_3d};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
@@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let name = str_p(p, "name").unwrap_or("Light 1").to_string();
|
||||
let t = time_p(s, p, Some(&comp));
|
||||
let id = s.edit("New Light", None, |proj, st| {
|
||||
// Only the Advanced 3D renderer lights with Environment lights, so adding one switches a
|
||||
// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step).
|
||||
let env = kind == LightKind::Environment;
|
||||
let (id, switched) = s.edit("New Light", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None);
|
||||
apply_light(&mut l, p, t, "layer.newLight")?;
|
||||
insert_layer(proj, st, cid, l)
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
Ok((id, if env { use_advanced_3d(proj, cid)? } else { false }))
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
report_switch(s, cid, switched, "which Environment lights need to light the scene");
|
||||
Ok(json!({"layer": id.0, "advanced3d": env}))
|
||||
}
|
||||
|
||||
fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
s.edit("Light Settings", None, |proj, _| {
|
||||
let switched = s.edit("Light Settings", None, |proj, _| {
|
||||
let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
|
||||
let mut l = old.clone();
|
||||
if let Some(k) = kind
|
||||
@@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.name = n.clone();
|
||||
}
|
||||
apply_light(&mut l, p, t, "layer.lightSettings")?;
|
||||
// A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D,
|
||||
// like adding one does; editing one that already is leaves the renderer to the user.
|
||||
let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment });
|
||||
let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
|
||||
*layer_mut(proj, cid, lid)? = l;
|
||||
Ok(())
|
||||
Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false })
|
||||
})?;
|
||||
report_switch(s, cid, switched, "which Environment lights need to light the scene");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -533,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.edit("Auto-Orient", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) {
|
||||
let rig = l.is_camera() || l.is_light();
|
||||
let ok = match mode {
|
||||
AutoOrient::Off | AutoOrient::AlongPath => true,
|
||||
@@ -683,7 +694,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Light...",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+L"),
|
||||
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
|
||||
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
|
||||
has_comp,
|
||||
new_light
|
||||
),
|
||||
@@ -710,7 +721,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Light Settings...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
|
||||
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
|
||||
has_comp,
|
||||
light_settings
|
||||
),
|
||||
|
||||
@@ -9,31 +9,35 @@
|
||||
//! render reads the new pixels.
|
||||
//!
|
||||
//! The automatic part compares each footage file's size and modification time with what they
|
||||
//! were when the file was last seen. The desktop app takes those stamps every two seconds (and
|
||||
//! when its window comes back to the front) on a worker thread, so a file on a slow network share
|
||||
//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them
|
||||
//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload
|
||||
//! records the state of the disk: it is not an undo step and doesn't modify the project.
|
||||
//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that
|
||||
//! stamps the files every two seconds while the window is in front (every ten seconds while it
|
||||
//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp
|
||||
//! changed, so a file on a slow network share never holds up the interface and an idle app never
|
||||
//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot
|
||||
//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records
|
||||
//! the state of the disk: it is not an undo step and doesn't modify the project.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::{Services, Session};
|
||||
|
||||
/// A file's size and modification time (see [`crate::Services::stamp`]).
|
||||
pub type Stamp = (u64, u128);
|
||||
|
||||
/// Stamps taken on a worker thread, waiting for [`poll_scan`].
|
||||
pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
|
||||
/// The stamps of a set of files, in path order.
|
||||
pub type Stamps = 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> {
|
||||
pub 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() {
|
||||
@@ -267,31 +271,253 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
|
||||
apply_stamps(s, now)
|
||||
}
|
||||
|
||||
/// Start stamping the watched files on a worker thread, unless a scan is still running (or the
|
||||
/// build has no threads: the web app, whose footage lives in browser storage).
|
||||
pub fn start_scan(s: &mut Session) {
|
||||
if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
/// How often the watched files are stamped while the window is in front.
|
||||
pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2);
|
||||
/// How often they are stamped while another app has the front: a file saved there is noticed
|
||||
/// as soon as the window comes back, so this only covers a second screen.
|
||||
pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
enum Watch {
|
||||
Paths(BTreeSet<String>),
|
||||
Focused(bool),
|
||||
}
|
||||
|
||||
/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands
|
||||
/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked,
|
||||
/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it
|
||||
/// what to watch and whether the window is in front; nothing watched means the thread sleeps.
|
||||
/// Dropping the watcher ends the thread.
|
||||
pub struct Watcher {
|
||||
tx: Sender<Watch>,
|
||||
result: Arc<Mutex<Option<Stamps>>>,
|
||||
paths: BTreeSet<String>,
|
||||
focused: Option<bool>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
/// Start the worker, stamping through `services`; `wake` runs on the worker whenever new
|
||||
/// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app,
|
||||
/// whose footage lives in browser storage) or one couldn't be started.
|
||||
pub fn start(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static) -> Option<Watcher> {
|
||||
Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL)
|
||||
}
|
||||
let paths = watched(s);
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
|
||||
/// [`Watcher::start`] with its own stamping intervals (tests).
|
||||
pub fn start_with(services: Arc<dyn Services>, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option<Watcher> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let result: Arc<Mutex<Option<Stamps>>> = Arc::default();
|
||||
let out = Arc::clone(&result);
|
||||
std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?;
|
||||
Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None })
|
||||
}
|
||||
let services = s.services.clone();
|
||||
let slot: Scan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || {
|
||||
let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect();
|
||||
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps);
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
s.footage_scan = Some(slot);
|
||||
|
||||
/// The files to watch ([`watched`]); a changed set is stamped at once.
|
||||
pub fn set_watched(&mut self, paths: BTreeSet<String>) {
|
||||
if paths != self.paths {
|
||||
self.paths = paths.clone();
|
||||
let _ = self.tx.send(Watch::Paths(paths));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window is in front; coming back to the front stamps the files at once.
|
||||
pub fn set_focused(&mut self, focused: bool) {
|
||||
if self.focused != Some(focused) {
|
||||
self.focused = Some(focused);
|
||||
let _ = self.tx.send(Watch::Focused(focused));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamps the worker found changed (or the first ones for a new set of files), once; for
|
||||
/// [`apply_stamps`]. `None` while nothing changed.
|
||||
pub fn take(&self) -> Option<Stamps> {
|
||||
self.result.lock().unwrap_or_else(PoisonError::into_inner).take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished scan (`None` while there is none, or it is still running).
|
||||
pub fn poll_scan(s: &mut Session) -> Option<crate::Result<Value>> {
|
||||
let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
|
||||
s.footage_scan = None;
|
||||
Some(apply_stamps(s, stamps))
|
||||
/// The watcher's worker: wait for a message or the interval, stamp, report only changes.
|
||||
fn watch(
|
||||
rx: &Receiver<Watch>,
|
||||
services: &dyn Services,
|
||||
out: &Mutex<Option<Stamps>>,
|
||||
wake: &(dyn Fn() + Send + Sync),
|
||||
focused_every: Duration,
|
||||
unfocused_every: Duration,
|
||||
) {
|
||||
let mut paths = BTreeSet::new();
|
||||
let mut focused = true;
|
||||
let mut last: Option<Stamps> = None;
|
||||
loop {
|
||||
let msg = if paths.is_empty() {
|
||||
rx.recv().map_err(|_| RecvTimeoutError::Disconnected)
|
||||
} else {
|
||||
rx.recv_timeout(if focused { focused_every } else { unfocused_every })
|
||||
};
|
||||
match msg {
|
||||
Ok(Watch::Paths(p)) => {
|
||||
paths = p;
|
||||
// A new set is reported whole, so the session forgets files no longer watched.
|
||||
last = None;
|
||||
}
|
||||
Ok(Watch::Focused(f)) => {
|
||||
let regained = f && !focused;
|
||||
focused = f;
|
||||
if !regained {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
if paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect();
|
||||
if last.as_ref() != Some(&stamps) {
|
||||
last = Some(stamps.clone());
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps);
|
||||
wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod watcher_tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files whose stamps the test sets; counts how often they were stamped.
|
||||
#[derive(Default)]
|
||||
struct Disk {
|
||||
stamps: Mutex<HashMap<String, Stamp>>,
|
||||
looks: AtomicUsize,
|
||||
}
|
||||
impl Disk {
|
||||
fn set(&self, path: &str, stamp: Stamp) {
|
||||
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp);
|
||||
}
|
||||
}
|
||||
impl Services for Disk {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("read-only"))
|
||||
}
|
||||
fn stamp(&self, path: &str) -> Option<Stamp> {
|
||||
self.looks.fetch_add(1, Ordering::SeqCst);
|
||||
self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied()
|
||||
}
|
||||
}
|
||||
|
||||
const TICK: Duration = Duration::from_millis(20);
|
||||
const LONG: Duration = Duration::from_secs(60);
|
||||
|
||||
fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps {
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if wakes.load(Ordering::SeqCst) >= n
|
||||
&& let Some(s) = w.take()
|
||||
{
|
||||
return s;
|
||||
}
|
||||
assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait (at most 10 s) until `ready`: a slow machine's worker gets there late, never early.
|
||||
fn wait_until(what: &str, mut ready: impl FnMut() -> bool) {
|
||||
let t0 = std::time::Instant::now();
|
||||
while !ready() {
|
||||
assert!(t0.elapsed() < Duration::from_secs(10), "{what} within 10 s");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn start(disk: &Arc<Disk>, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc<AtomicUsize>)> {
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&wakes);
|
||||
let w = Watcher::start_with(
|
||||
Arc::clone(disk) as Arc<dyn Services>,
|
||||
move || {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
},
|
||||
focused,
|
||||
unfocused,
|
||||
)?;
|
||||
Some((w, wakes))
|
||||
}
|
||||
|
||||
/// The interface is woken for the first stamps of a set and for every change, and for
|
||||
/// nothing else: an unchanged file polled many times is no wakeup at all.
|
||||
#[test]
|
||||
fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() {
|
||||
let disk = Arc::new(Disk::default());
|
||||
disk.set("/a.psd", (10, 1));
|
||||
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
|
||||
w.set_focused(true);
|
||||
w.set_watched(["/a.psd".to_string()].into());
|
||||
assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]);
|
||||
let looked = disk.looks.load(Ordering::SeqCst);
|
||||
wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3);
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup");
|
||||
assert!(w.take().is_none());
|
||||
disk.set("/a.psd", (12, 2));
|
||||
assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]);
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// Behind another app the files are stamped only at the slow interval; the window coming
|
||||
/// back to the front stamps them at once.
|
||||
#[test]
|
||||
fn behind_another_app_the_watcher_rests_until_the_window_is_back() {
|
||||
let disk = Arc::new(Disk::default());
|
||||
disk.set("/b.psd", (1, 1));
|
||||
let (mut w, wakes) = start(&disk, TICK, LONG).unwrap();
|
||||
w.set_focused(false);
|
||||
w.set_watched(["/b.psd".to_string()].into());
|
||||
wait_for(&w, &wakes, 1);
|
||||
let looked = disk.looks.load(Ordering::SeqCst);
|
||||
disk.set("/b.psd", (2, 2));
|
||||
std::thread::sleep(TICK * 15);
|
||||
assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind");
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 1);
|
||||
w.set_focused(true);
|
||||
assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]);
|
||||
}
|
||||
|
||||
/// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and
|
||||
/// reported whole, and dropping the watcher ends the worker.
|
||||
#[test]
|
||||
fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() {
|
||||
let disk = Arc::new(Disk::default());
|
||||
disk.set("/c.psd", (1, 1));
|
||||
disk.set("/d.psd", (2, 2));
|
||||
let (mut w, wakes) = start(&disk, TICK, TICK).unwrap();
|
||||
w.set_focused(true);
|
||||
std::thread::sleep(TICK * 10);
|
||||
assert_eq!(disk.looks.load(Ordering::SeqCst), 0);
|
||||
w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into());
|
||||
assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]);
|
||||
w.set_watched(["/d.psd".to_string()].into());
|
||||
assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd");
|
||||
w.set_watched(BTreeSet::new());
|
||||
// The worker may already be past its wait with the old set: that one round of stamps
|
||||
// (one file) can still come, and nothing after it.
|
||||
let looked = disk.looks.load(Ordering::SeqCst);
|
||||
std::thread::sleep(TICK * 10);
|
||||
assert!(disk.looks.load(Ordering::SeqCst) <= looked + 1, "nothing watched, nothing stamped");
|
||||
let result = Arc::downgrade(&w.result);
|
||||
drop(w);
|
||||
let t0 = std::time::Instant::now();
|
||||
while result.upgrade().is_some() {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||
}
|
||||
|
||||
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
|
||||
@@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
let e = ext.as_str();
|
||||
if VIDEO.contains(&e) {
|
||||
// (An animated GIF plays as a movie.)
|
||||
if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) {
|
||||
Some("video")
|
||||
} else if IMAGE.contains(&e) {
|
||||
Some("image")
|
||||
@@ -431,6 +431,15 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Every movie type the importer reads lists as video, as an animated GIF does.
|
||||
#[test]
|
||||
fn movies_list_as_video() {
|
||||
for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) {
|
||||
assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}");
|
||||
}
|
||||
assert_eq!(kind_of("/clips/capture.avi"), Some("video"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_tree_lists_like_a_folder() {
|
||||
let mut t = VirtualTree::default();
|
||||
|
||||
@@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp {
|
||||
[440.0, 914.0],
|
||||
);
|
||||
anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))]));
|
||||
c.layers = vec![title, bar];
|
||||
c.layers = vec![title, bar].into();
|
||||
c
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
|
||||
);
|
||||
anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))]));
|
||||
|
||||
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor];
|
||||
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into();
|
||||
c
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ pub fn demo_project() -> Project {
|
||||
lower.out_point = t(9.5);
|
||||
|
||||
// Stack: top first.
|
||||
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg];
|
||||
comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into();
|
||||
for l in &mut comp.layers {
|
||||
if l.name == "Controller" {
|
||||
l.switches.video = false;
|
||||
|
||||
@@ -241,8 +241,6 @@ pub struct Session {
|
||||
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
|
||||
/// Reload Footage Changed on Disk, [`footage_reload`]).
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
|
||||
pub footage_scan: Option<footage_reload::Scan>,
|
||||
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
|
||||
pub disk_salt: Option<(u64, u64)>,
|
||||
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
|
||||
@@ -351,7 +349,6 @@ impl Default for Session {
|
||||
stored_paths: Default::default(),
|
||||
footage_stamps: Default::default(),
|
||||
disk_salt: None,
|
||||
footage_scan: None,
|
||||
script_calls: ScriptCalls::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,6 +1036,8 @@ Animation
|
||||
---
|
||||
Add Keyframe | anim.addKeyframe
|
||||
Toggle Hold Keyframe | keys.toggleHold
|
||||
Mute Keyframes | keys.setMuted
|
||||
Unmute Keyframes | keys.setMuted {"muted":false}
|
||||
Keyframe Interpolation... | keys.interpolation
|
||||
Keyframe Velocity... | keys.velocity
|
||||
Keyframe Assistant
|
||||
|
||||
@@ -686,7 +686,7 @@ impl Session {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) {
|
||||
for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) {
|
||||
l.source = LayerSource::Footage { item: fid };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,12 +402,16 @@ pub struct ScriptUi {
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then.
|
||||
/// tasks of scripts without an open window stop then, except those of Startup scripts.
|
||||
#[serde(skip)]
|
||||
pub project_epoch: u64,
|
||||
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
|
||||
#[serde(skip)]
|
||||
pub task_hosts: Vec<u32>,
|
||||
/// The script contexts of Startup scripts: their tasks go on when the project is closed or
|
||||
/// replaced, until EffectCraft quits.
|
||||
#[serde(skip)]
|
||||
pub startup_hosts: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ScriptUi {
|
||||
@@ -755,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"screenX": num("screenX", -FAR, FAR, 0.0),
|
||||
"screenY": num("screenY", -FAR, FAR, 0.0),
|
||||
"button": num("button", 0.0, 2.0, 0.0).round(),
|
||||
"buttons": num("buttons", 0.0, 7.0, 0.0).round(),
|
||||
"detail": num("detail", 0.0, 1000.0, clicks).round(),
|
||||
"altKey": flag("altKey"),
|
||||
"ctrlKey": flag("ctrlKey"),
|
||||
@@ -764,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
|
||||
}
|
||||
|
||||
/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the
|
||||
/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`.
|
||||
pub(crate) fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "scriptui.resize";
|
||||
let window = window_p(s, p, c)?;
|
||||
let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?;
|
||||
let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0));
|
||||
let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) };
|
||||
dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) })
|
||||
}
|
||||
|
||||
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
|
||||
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let window = window_p(s, p, "scriptui.close")?;
|
||||
|
||||
@@ -96,7 +96,7 @@ impl Session {
|
||||
effectcraft_project::ItemKind::Footage(f) if f.has_video => {
|
||||
let f = f.clone();
|
||||
Some(Box::new(move || {
|
||||
let img = footage.frame(item, &f, Tick(0))?;
|
||||
let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f);
|
||||
// Point-sample down to at most `max_side` on the long side.
|
||||
let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32;
|
||||
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
|
||||
|
||||
@@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Deutsch", "de"),
|
||||
("Español", "es"),
|
||||
("Français", "fr"),
|
||||
("Polski", "pl"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Українська", "uk"),
|
||||
|
||||
@@ -153,7 +153,8 @@ page!(Import {
|
||||
sequence_fps: f64 = 30.0,
|
||||
report_missing_frames: bool = true,
|
||||
/// Automatically Reload Footage whose file another app changed (size or modification time),
|
||||
/// checked every two seconds and when the window comes back to the front: `off`,
|
||||
/// checked every two seconds while the window is in front, every ten seconds while it is
|
||||
/// not, and when it comes back to the front: `off`,
|
||||
/// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`.
|
||||
auto_reload_footage: String = "nonSequence".into(),
|
||||
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
|
||||
|
||||
@@ -195,9 +195,19 @@ impl Session {
|
||||
self.shortcut_table = std::sync::OnceLock::new();
|
||||
}
|
||||
|
||||
/// The active shortcut preset resolved against every bindable command.
|
||||
/// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the
|
||||
/// shortcut table.
|
||||
pub fn scripts_changed(&mut self) {
|
||||
self.shortcut_table = std::sync::OnceLock::new();
|
||||
}
|
||||
|
||||
/// The active shortcut preset resolved against every bindable command, File ▸ Scripts'
|
||||
/// entries included.
|
||||
pub fn shortcuts(&self) -> &ShortcutTable {
|
||||
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
|
||||
self.shortcut_table.get_or_init(|| {
|
||||
let scripts: Vec<String> = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect();
|
||||
ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts)
|
||||
})
|
||||
}
|
||||
|
||||
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts).
|
||||
//!
|
||||
//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key
|
||||
//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts
|
||||
//! lists, is *bindable*: it has a binding key
|
||||
//! (the command id, plus its bound parameters for entries such as `app.settings
|
||||
//! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in
|
||||
//! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom
|
||||
@@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with
|
||||
/// no default keys. A key is bound to the script's name, so it stays with that script when
|
||||
/// scripts are added to or removed from the folder.
|
||||
pub fn script_bindables(names: &[String]) -> Vec<Bindable> {
|
||||
names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let params = json!({"name": n});
|
||||
Bindable {
|
||||
key: binding_key("file.runScript", ¶ms),
|
||||
command: "file.runScript".into(),
|
||||
params,
|
||||
label: n.clone(),
|
||||
path: vec!["File".into(), "Scripts".into()],
|
||||
scope: APP_SCOPE.into(),
|
||||
defaults: vec![],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it
|
||||
/// has no key.
|
||||
pub fn normalize(s: &str) -> Option<String> {
|
||||
@@ -436,7 +458,16 @@ pub struct ShortcutTable {
|
||||
|
||||
impl ShortcutTable {
|
||||
pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable {
|
||||
let bindables = bindables(ui);
|
||||
Self::build_with_scripts(maps, ui, &[])
|
||||
}
|
||||
|
||||
/// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed
|
||||
/// after the other File ▸ Scripts entries.
|
||||
pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable {
|
||||
let mut bindables = bindables(ui);
|
||||
let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1);
|
||||
let new: Vec<Bindable> = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect();
|
||||
bindables.splice(at..at, new);
|
||||
let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect();
|
||||
ShortcutTable { bindables, keys }
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project {
|
||||
*n += 1;
|
||||
layers.push(l);
|
||||
}
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
};
|
||||
let others = spec.comps.saturating_sub(1);
|
||||
let depth = spec.nest_depth.min(others);
|
||||
@@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project {
|
||||
n += 1;
|
||||
layers.push(l);
|
||||
}
|
||||
main.layers = layers;
|
||||
main.layers = layers.into();
|
||||
p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main)));
|
||||
// `Main` must be the first comp `replace_project` opens: give it the smallest id.
|
||||
let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod anim_presets;
|
||||
mod anim_tools;
|
||||
mod blender;
|
||||
mod camera_track;
|
||||
mod codec_options;
|
||||
mod color_view;
|
||||
@@ -41,6 +42,7 @@ pub(crate) mod roto;
|
||||
mod sequence;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod shared_layers;
|
||||
mod shortcut_cmds;
|
||||
pub(crate) mod stubs;
|
||||
mod styles;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public
|
||||
//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture.
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn path(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn fixture(ground: bool) -> (Session, LayerId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let rate = FrameRate::new(30000, 1001);
|
||||
let lid = s
|
||||
.edit("fixture", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().frame_rate = rate;
|
||||
let item = p.add_item(
|
||||
"Plate",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Footage(Footage {
|
||||
path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")),
|
||||
width: 640,
|
||||
height: 360,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
duration: Tick::from_seconds_f64(6.0),
|
||||
kind: FootageKind::Video,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None);
|
||||
layer.in_point = rate.tick_of(60);
|
||||
layer.out_point = rate.tick_of(63);
|
||||
layer.start_time = rate.tick_of(30);
|
||||
layer.stretch = 200.0;
|
||||
let id = layer.id;
|
||||
p.comp_mut(cid).unwrap().layers.push(layer);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let solve = CameraSolve {
|
||||
version: 1,
|
||||
size: [640.0, 360.0],
|
||||
start: rate.tick_of(30).seconds() / 2.0,
|
||||
frame_duration: rate.frame_duration().seconds() / 2.0,
|
||||
shot: ShotType::VariableZoom,
|
||||
method_used: SolveMethod::Typical,
|
||||
average_error: 0.12,
|
||||
frames: (0..3)
|
||||
.map(|i| SolvedFrame {
|
||||
rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64],
|
||||
center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64],
|
||||
focal: 600.0 + i as f64 * 25.0,
|
||||
solved: true,
|
||||
})
|
||||
.collect(),
|
||||
points: vec![
|
||||
SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 },
|
||||
SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 },
|
||||
],
|
||||
ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }),
|
||||
distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }),
|
||||
};
|
||||
// Directly install a deterministic solve; no analysis or decoder is needed by these tests.
|
||||
s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||||
s.camera_pending.clear();
|
||||
s.execute("layer.select", json!({"layers": [lid.0]})).unwrap();
|
||||
(s, lid, uid)
|
||||
}
|
||||
|
||||
fn data(script: &str) -> Value {
|
||||
let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap();
|
||||
let encoded: String = serde_json::from_str(line).unwrap();
|
||||
serde_json::from_str(&encoded).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_bakes_projection_focus_and_retimed_background() {
|
||||
let (mut s, lid, uid) = fixture(true);
|
||||
let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap();
|
||||
let camera = result["camera"].as_u64().unwrap();
|
||||
s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap();
|
||||
for (frame, value) in [(60, 800.0), (62, 1000.0)] {
|
||||
let time = FrameRate::new(30000, 1001).tick_of(frame).seconds();
|
||||
s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap();
|
||||
assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool());
|
||||
let output = path("oracle-scene.py");
|
||||
let before = s.project.to_json();
|
||||
let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before, "export must not edit the document");
|
||||
assert_eq!(r["frames"], 3);
|
||||
assert_eq!(r["points"], 2);
|
||||
let script = std::fs::read_to_string(&output).unwrap();
|
||||
let d = data(&script);
|
||||
assert_eq!(d["resolution"], json!([640.0, 360.0]));
|
||||
assert_eq!(d["fpsNumerator"], 30000);
|
||||
assert_eq!(d["fpsDenominator"], 1001);
|
||||
assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101");
|
||||
assert_eq!(d["frames"][2]["sourceFrame"], 16);
|
||||
assert_eq!(d["frames"][0]["focus"], 8.0);
|
||||
assert_eq!(d["frames"][2]["focus"], 10.0);
|
||||
assert_eq!(d["frames"][0]["dof"], true);
|
||||
assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6);
|
||||
assert_eq!(d["frames"][0]["lens"], 33.75);
|
||||
assert_eq!(d["frames"][2]["lens"], 36.5625);
|
||||
assert_eq!(d["ground"], true);
|
||||
assert_eq!(d["solve"]["distortion"]["k1"], 0.01);
|
||||
assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane");
|
||||
|
||||
// Execute the actual exported artifact in a separately installed Blender, when requested.
|
||||
if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") {
|
||||
let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py");
|
||||
let output = std::process::Command::new(blender)
|
||||
.args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"])
|
||||
.arg(oracle)
|
||||
.arg("--")
|
||||
.arg(&output)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_bakes_reverse_time_remap_and_footage_loops() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let rate = FrameRate::new(30000, 1001);
|
||||
s.edit("retimed fixture", None, |p, _| {
|
||||
let layer = p.comp(cid).unwrap().layer(lid).unwrap();
|
||||
let item = layer.source.item().unwrap();
|
||||
let keys = [(60, 8.0), (61, 6.0), (62, -1.0)]
|
||||
.map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time)));
|
||||
let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0));
|
||||
remap.keys = keys.to_vec();
|
||||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into());
|
||||
if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind {
|
||||
f.loop_count = 2;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let output = path("remap.py");
|
||||
s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap();
|
||||
let d = data(&std::fs::read_to_string(output).unwrap());
|
||||
let indices: Vec<i64> = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect();
|
||||
assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_defaults_and_script_escaping() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let output = path("escaped.py");
|
||||
let r = s
|
||||
.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1}))
|
||||
.unwrap();
|
||||
assert_eq!(r["points"], 0);
|
||||
let script = std::fs::read_to_string(&output).unwrap();
|
||||
let d = data(&script);
|
||||
assert!(d["name"].as_str().unwrap().contains("'\\ — câmera"));
|
||||
assert_eq!(d["frames"][0]["lens"], 22.5);
|
||||
assert_eq!(d["frames"][0]["dof"], false);
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered")));
|
||||
match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() {
|
||||
Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"),
|
||||
Err(e) => panic!("Python AST oracle failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_rejects_invalid_arguments_and_write_failures() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
for params in [
|
||||
json!({"sensorWidth": 0}),
|
||||
json!({"sensorWidth": "wide"}),
|
||||
json!({"unitScale": -1}),
|
||||
json!({"startFrame": -1}),
|
||||
json!({"startFrame": 1.5}),
|
||||
json!({"startFrame": 1048574}),
|
||||
json!({"includePoints": "yes"}),
|
||||
json!({"bogus": true}),
|
||||
json!({"path": path("bad.blend")}),
|
||||
] {
|
||||
let mut p = json!({"layer": lid.0, "path": path("invalid.py")});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}");
|
||||
}
|
||||
let blocker = path("blocker");
|
||||
std::fs::write(&blocker, b"not a directory").unwrap();
|
||||
let p = format!("{blocker}/scene.py");
|
||||
assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_rejects_malformed_solves_without_panicking() {
|
||||
for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] {
|
||||
let (mut s, lid, uid) = fixture(false);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap()))
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.clone();
|
||||
match variant {
|
||||
"empty" => solve.frames.clear(),
|
||||
"unsolved" => solve.frames[0].solved = false,
|
||||
"zero-focal" => solve.frames[0].focal = 0.0,
|
||||
"bad-ground" => solve.ground = Some(Ground::default()),
|
||||
"huge-resolution" => solve.size[0] = 1e10,
|
||||
_ => {
|
||||
solve.frames.pop();
|
||||
}
|
||||
}
|
||||
s.edit("corrupt solve", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.camera_pending.clear();
|
||||
let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")}));
|
||||
assert!(result.is_err(), "must reject {variant}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_export_respects_script_write_permission() {
|
||||
let (mut s, lid, _) = fixture(false);
|
||||
let output = path("script-permissions.py");
|
||||
let params = json!({"layer": lid.0, "path": output});
|
||||
for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] {
|
||||
std::fs::write(&output, b"existing file").unwrap();
|
||||
s.prefs.scripting.allow_scripts_write_files = allowed;
|
||||
let result =
|
||||
if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) };
|
||||
assert!(!s.running_for_script(), "script origin must be restored after success or error");
|
||||
let contents = std::fs::read_to_string(&output).unwrap();
|
||||
if from_script && !allowed {
|
||||
assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files"));
|
||||
assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file");
|
||||
} else {
|
||||
assert_eq!(result.unwrap()["frames"], 3);
|
||||
assert!(contents.contains("def import_scene():"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() {
|
||||
effectcraft_project::ItemKind::Comp(c) => c,
|
||||
_ => panic!("not a composition"),
|
||||
});
|
||||
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
|
||||
let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap();
|
||||
for n in &mut l.props.sub_mut("effects").unwrap().children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
if g.match_id == "ec.blur.gaussian" {
|
||||
|
||||
@@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muting_keys_skips_them_in_the_timeline_value_and_undoes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
|
||||
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
select(&mut s);
|
||||
let before = value_at(&s, 0.5);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
// The muted key no longer bends the 0 → 300 segment.
|
||||
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
|
||||
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
|
||||
s.undo();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
|
||||
// The same command unmutes.
|
||||
select(&mut s);
|
||||
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
|
||||
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
|
||||
}
|
||||
|
||||
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
|
||||
#[test]
|
||||
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
|
||||
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
|
||||
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut menu = vec![];
|
||||
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
|
||||
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
|
||||
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
let run = |s: &mut Session, params: &Value| {
|
||||
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
|
||||
};
|
||||
run(&mut s, &menu[0].1);
|
||||
assert!(muted(&s));
|
||||
s.undo();
|
||||
assert!(!muted(&s));
|
||||
s.redo();
|
||||
assert!(muted(&s));
|
||||
run(&mut s, &menu[1].1);
|
||||
assert!(!muted(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() {
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
|
||||
}
|
||||
|
||||
/// A proxy in use that can't be decoded leaves its item without a picture although the item's
|
||||
/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the
|
||||
/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off.
|
||||
#[test]
|
||||
fn a_proxy_in_use_that_cannot_be_decoded_reports_why() {
|
||||
struct BrokenProxies;
|
||||
impl FootageSource for BrokenProxies {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn problem(&self, f: &Footage) -> Option<String> {
|
||||
f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() };
|
||||
let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() };
|
||||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still));
|
||||
let proxy = Footage {
|
||||
kind: FootageKind::Video,
|
||||
path: "plate.avi".into(),
|
||||
codec: "MPEG-4 Part 2".into(),
|
||||
width: 32,
|
||||
height: 18,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder");
|
||||
let problem = |s: &mut Session, id: u64| {
|
||||
let sum = s.execute("project.summary", json!({})).unwrap();
|
||||
sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned()
|
||||
};
|
||||
for id in [item.0, comp] {
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true }));
|
||||
assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}");
|
||||
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false;
|
||||
assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine");
|
||||
}
|
||||
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true;
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
|
||||
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
|
||||
//! a synthetic footage source.
|
||||
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings),
|
||||
//! rendered through a synthetic footage source.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
|
||||
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
|
||||
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
|
||||
"half.png" => [1.0, 1.0, 1.0, 0.25],
|
||||
// A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands
|
||||
// them out (code / 1023).
|
||||
"scan.dpx" | "plate.dpx" => {
|
||||
let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0;
|
||||
[v, v, v, 1.0]
|
||||
}
|
||||
_ => {
|
||||
let _ = x;
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
@@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) {
|
||||
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
|
||||
"red16.png" => (16, 16),
|
||||
"blue8.png" => (8, 8),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4),
|
||||
_ => (10, 10),
|
||||
}
|
||||
}
|
||||
@@ -64,7 +70,12 @@ impl FootageSource for Synthetic {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
let p = file_px(&f.path, x, y);
|
||||
let mut p = file_px(&f.path, x, y);
|
||||
// A log scan sequence: code 95 on even frames, 685 on odd ones.
|
||||
if f.path.ends_with("frames.dpx") {
|
||||
let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0;
|
||||
p = [v, v, v, 1.0];
|
||||
}
|
||||
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +87,7 @@ struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let (w, h) = size_of(path);
|
||||
let video = path.ends_with(".mov");
|
||||
let video = path.ends_with(".mov") || path.ends_with("frames.dpx");
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if video { FootageKind::Video } else { FootageKind::Still },
|
||||
@@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() {
|
||||
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
|
||||
assert!(foot(&s, w).preserve_rgb);
|
||||
}
|
||||
|
||||
/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the
|
||||
/// Current Gamma between the converted points (off, they show as stored); the converted points
|
||||
/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX
|
||||
/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings.
|
||||
#[test]
|
||||
fn cineon_settings_convert_dpx_code_values() {
|
||||
let mut s = session();
|
||||
let d = import(&mut s, "/m/scan.dpx");
|
||||
let other = import(&mut s, "/m/plate.dpx");
|
||||
let png = import(&mut s, "/m/red16.png");
|
||||
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
|
||||
let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0];
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
|
||||
// Off: the code values as stored.
|
||||
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
|
||||
// The checked path (control channel, MCP) accepts every Cineon parameter the doc lists.
|
||||
let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}");
|
||||
assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1));
|
||||
assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2));
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap();
|
||||
assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2));
|
||||
// Converted points, in 8-bit units.
|
||||
let out = s
|
||||
.execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685}))
|
||||
.unwrap();
|
||||
assert_eq!(out["items"][0]["cineon"]["units"], "8bit");
|
||||
assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}");
|
||||
assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1));
|
||||
// The form sends the points in the units it showed them in (`pointUnits`) with a new Units.
|
||||
s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap();
|
||||
let f = foot(&s, d);
|
||||
assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float);
|
||||
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
|
||||
// Refused: a black point at or above the white point, values out of range, and footage
|
||||
// that is not DPX or Cineon. Nothing changes.
|
||||
let before = foot(&s, d).cineon;
|
||||
for bad in [
|
||||
json!({"item": d.0, "tenBitBlack": 700}),
|
||||
json!({"item": d.0, "tenBitWhite": 2000}),
|
||||
json!({"item": d.0, "currentGamma": 0}),
|
||||
json!({"item": d.0, "highlightRolloff": -1}),
|
||||
json!({"item": d.0, "convertedWhite": 1.5}),
|
||||
json!({"item": d.0, "cineonUnits": "12bit"}),
|
||||
json!({"item": png.0, "logConversion": true}),
|
||||
] {
|
||||
assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(foot(&s, d).cineon, before);
|
||||
assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default());
|
||||
// With DPX and other footage selected, only the DPX file takes the settings.
|
||||
s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap();
|
||||
assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!foot(&s, other).cineon.log_conversion);
|
||||
// Remember / Apply Interpretation.
|
||||
s.state.project_selection = vec![d];
|
||||
s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap();
|
||||
s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap();
|
||||
assert_eq!(foot(&s, other).cineon, before);
|
||||
assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default());
|
||||
// Off again: as stored.
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap();
|
||||
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
|
||||
// Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at
|
||||
// Current Gamma 1 is 0.5, not the conversion of the blended code values.
|
||||
let seq = import(&mut s, "/m/frames.dpx");
|
||||
s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap();
|
||||
let bc = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": seq.0})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
|
||||
let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0];
|
||||
assert!(near(mid, 0.5), "frame-mixed log frames: {mid}");
|
||||
// A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in.
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap();
|
||||
let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0];
|
||||
assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1));
|
||||
s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1));
|
||||
}
|
||||
|
||||
/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX
|
||||
/// footage with its Cineon Settings, as a composition renders it.
|
||||
#[test]
|
||||
fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() {
|
||||
let mut s = session();
|
||||
let d = import(&mut s, "/m/scan.dpx");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
|
||||
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap();
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
|
||||
let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0];
|
||||
assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}");
|
||||
let f = match &s.project.item(d).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0];
|
||||
assert!(near(panel, rendered), "Footage panel: {panel}");
|
||||
let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap();
|
||||
assert_eq!(w, 4);
|
||||
let thumb = rgba[8] as f32 / 255.0;
|
||||
assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}");
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
|
||||
let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap();
|
||||
assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() {
|
||||
s.execute("file.import", params).unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
|
||||
for l in &comp.layers[..2] {
|
||||
for l in comp.layers.iter().take(2) {
|
||||
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
@@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() {
|
||||
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what
|
||||
/// the import made, first file on top, at once or when the background job lands; an import that
|
||||
/// made nothing makes no comp.
|
||||
#[test]
|
||||
fn new_comp_from_footage_makes_a_comp_of_what_was_imported() {
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if path.ends_with(".xyz") {
|
||||
return Err("unsupported file".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count();
|
||||
let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
// At once.
|
||||
s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 1);
|
||||
assert_eq!(layers(&s), ["a.png"]);
|
||||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32));
|
||||
// In the background: the job's own items, whatever is selected or active meanwhile; it takes
|
||||
// precedence over `addToComp`.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(layers(&s), ["c.png", "d.png"]);
|
||||
// Nothing imported: no comp, and the failure is reported.
|
||||
s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert!(s.job_log.last().unwrap().message.contains("unsupported file"));
|
||||
let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap();
|
||||
assert_eq!(comps(&s), 2);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
|
||||
@@ -1089,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
// Polish, likewise.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl"));
|
||||
let mut pl = Session { config: Some(store.clone()), ..Default::default() };
|
||||
pl.load_settings();
|
||||
assert_eq!(pl.prefs.general.language, "pl");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl");
|
||||
// Georgian too.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! An edit copies only the layer it changes: the others stay shared with the undo state, so an
|
||||
//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap();
|
||||
let ids: Vec<u64> = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let before = s.project.clone();
|
||||
s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap();
|
||||
let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap());
|
||||
assert_eq!(old.layers.len(), new.layers.len());
|
||||
let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap();
|
||||
for i in 0..new.layers.len() {
|
||||
assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i));
|
||||
}
|
||||
// Undo brings back the very same state, and the edited layer is the only one that differed.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.project, &before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_save_and_load_as_a_plain_list() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap();
|
||||
for i in 0..3 {
|
||||
s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap();
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let text = serde_json::to_value(&comp.layers).unwrap();
|
||||
assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before");
|
||||
let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap();
|
||||
assert_eq!(&back, &comp.layers);
|
||||
}
|
||||
@@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so
|
||||
/// the editable path is the shape that was on screen.
|
||||
#[test]
|
||||
fn convert_to_bezier_honours_per_corner_roundness() {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
|
||||
let per_corner = uid_of(&s, "perCorner");
|
||||
let top_left = uid_of(&s, "topLeftRoundness");
|
||||
let bottom_right = uid_of(&s, "bottomRightRoundness");
|
||||
for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] {
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap();
|
||||
}
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap();
|
||||
assert_eq!(path, expected, "the converted path has the per-corner geometry");
|
||||
}
|
||||
|
||||
/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven
|
||||
/// Per-Corner checkbox and radius convert to the geometry on screen.
|
||||
#[test]
|
||||
fn convert_to_bezier_evaluates_corner_expressions() {
|
||||
struct Corners;
|
||||
impl effectcraft_render::ExprHost for Corners {
|
||||
fn eval(
|
||||
&self,
|
||||
_: &effectcraft_render::EvalCtx,
|
||||
_: &effectcraft_project::Layer,
|
||||
prop: &effectcraft_project::Property,
|
||||
value: &KV,
|
||||
) -> std::result::Result<KV, String> {
|
||||
Ok(match prop.match_id.as_str() {
|
||||
"perCorner" => KV::Bool(true),
|
||||
"topLeftRoundness" => KV::Scalar(20.0),
|
||||
_ => value.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = comp();
|
||||
s.expr = Some(Arc::new(Corners));
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid;
|
||||
for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] {
|
||||
let u = uid_of(&s, id);
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap();
|
||||
}
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap();
|
||||
assert_eq!(path, expected, "the expression-evaluated corner is converted");
|
||||
}
|
||||
|
||||
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
|
||||
/// shared merge key; one undo must revert the whole drag, not the last frame.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
|
||||
|
||||
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
|
||||
use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer};
|
||||
use effectcraft_render::three_d::View3D;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() {
|
||||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
|
||||
}
|
||||
|
||||
/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are
|
||||
/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches
|
||||
/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does.
|
||||
#[test]
|
||||
fn environment_lights_switch_classic_comps_to_advanced_3d() {
|
||||
let toasts = |s: &mut Session| -> Vec<String> {
|
||||
s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()
|
||||
};
|
||||
let mut s = session();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
toasts(&mut s);
|
||||
let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
|
||||
assert_eq!(r["advanced3d"], true);
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
// As with Composition Settings: the text layer gets Geometry Options.
|
||||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some());
|
||||
assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));
|
||||
// A second Environment light leaves the comp (and the user) alone.
|
||||
s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
|
||||
assert!(toasts(&mut s).is_empty());
|
||||
// Undoing the creations takes the lights and the switch back together.
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
// Changing a light to Environment switches too, and undoes with the type.
|
||||
let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
|
||||
s.undo();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot });
|
||||
// Other light types never switch it.
|
||||
s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
|
||||
// Switching back to Classic 3D with an Environment light in the comp explains itself.
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
|
||||
toasts(&mut s);
|
||||
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
|
||||
assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained");
|
||||
// Editing that Environment light (no type change) leaves the renderer the user chose.
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch");
|
||||
assert!(toasts(&mut s).is_empty(), "and doesn't toast either");
|
||||
}
|
||||
|
||||
/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every
|
||||
/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no
|
||||
/// Falloff, which an environment light shining from every direction has no use for (#2436).
|
||||
#[test]
|
||||
fn environment_light_takes_shadow_options_but_no_falloff() {
|
||||
let mut s = session();
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0);
|
||||
assert!(
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(),
|
||||
"an environment light has no Falloff"
|
||||
);
|
||||
// The shadow values carry over when the type changes, both ways.
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
|
||||
assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0);
|
||||
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap();
|
||||
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
|
||||
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again");
|
||||
s.undo();
|
||||
assert!(
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(),
|
||||
"back to Spot: it has a Cone Angle again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_switch_undo_redo() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -160,6 +160,37 @@ fn psd_imports_as_composition() {
|
||||
assert!(s.project.comp(cid).is_none());
|
||||
}
|
||||
|
||||
/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that
|
||||
/// composition; it isn't wrapped in another.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_with_new_comp_is_the_new_comp() {
|
||||
let path = tmp("newcomp.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count();
|
||||
assert_eq!(roots, 1, "no wrapper comp");
|
||||
}
|
||||
|
||||
/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds
|
||||
/// its composition once, not its folder too.
|
||||
#[test]
|
||||
fn psd_imported_as_composition_into_a_comp_adds_one_layer() {
|
||||
let path = tmp("dropped.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let psd = r["comps"][0].as_u64().unwrap();
|
||||
let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers;
|
||||
assert_eq!(layers.len(), 1, "{layers:?}");
|
||||
assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_retain_layer_sizes() {
|
||||
let path = tmp("retain.psd");
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Public-API oracle for scripts generated by src/tests/blender.rs; no Blender source dependency."""
|
||||
import json
|
||||
import math
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from bpy_extras.object_utils import world_to_camera_view
|
||||
from mathutils import Quaternion, Vector
|
||||
|
||||
path = sys.argv[sys.argv.index("--") + 1]
|
||||
before = set(bpy.data.scenes)
|
||||
exported = runpy.run_path(path, run_name="__main__")
|
||||
scene = exported["EFFECTCRAFT_SCENE"]
|
||||
data = exported["DATA"]
|
||||
assert before.issubset(set(bpy.data.scenes)), "existing scenes were removed"
|
||||
assert scene not in before
|
||||
assert scene.frame_start == 101 and scene.frame_end == 103
|
||||
assert abs(scene.render.fps / scene.render.fps_base - 30000 / 1001) < 1e-4
|
||||
assert (scene.render.resolution_x, scene.render.resolution_y) == (640, 360)
|
||||
camera = scene.camera
|
||||
assert camera.animation_data.action is not None
|
||||
assert camera.data.animation_data.action is not None
|
||||
assert camera.data.sensor_fit == 'HORIZONTAL'
|
||||
metadata = json.loads(bpy.data.texts[scene["effectcraft_metadata_text"]].as_string())
|
||||
assert metadata["solve"]["averageError"] == 0.12
|
||||
cloud = scene.objects["Solved Track Points"]
|
||||
assert len(cloud.data.vertices) == 2
|
||||
assert cloud.hide_render
|
||||
assert cloud.data.attributes["track_id"].data[0].value == 7
|
||||
assert any(obj.name.startswith("Solved Ground Plane") for obj in scene.objects)
|
||||
|
||||
for i, sample in enumerate(data["frames"]):
|
||||
scene.frame_set(sample["frame"])
|
||||
bpy.context.view_layer.update()
|
||||
assert abs(camera.data.lens - sample["lens"]) < 1e-5
|
||||
assert abs(camera.data.dof.focus_distance - sample["focus"]) < 1e-5
|
||||
assert abs(camera.data.dof.aperture_fstop - sample["fstop"]) < 1e-5
|
||||
assert camera.data.dof.use_dof
|
||||
assert (camera.location - Vector(sample["location"])).length < 1e-5
|
||||
raw = data["solve"]["frames"][i]
|
||||
rot = Vector(raw["rot"])
|
||||
rotation = Quaternion(rot.normalized(), rot.length).to_matrix() if rot.length else Quaternion().to_matrix()
|
||||
for vertex, point in zip(cloud.data.vertices, data["solve"]["points"]):
|
||||
relative = rotation @ (Vector(point["pos"]) - Vector(raw["center"]))
|
||||
expected = (raw["focal"] * relative.x / relative.z + 320,
|
||||
raw["focal"] * relative.y / relative.z + 180)
|
||||
projected = world_to_camera_view(scene, camera, vertex.co)
|
||||
actual = (projected.x * 640, (1.0 - projected.y) * 360)
|
||||
assert math.dist(actual, expected) < 0.002, (sample["frame"], actual, expected)
|
||||
|
||||
if "Video not found" not in " ".join(data["warnings"]):
|
||||
background = camera.data.background_images[0]
|
||||
assert background.image.source == 'MOVIE'
|
||||
assert tuple(background.image.size) == (640, 360)
|
||||
user = background.image_user
|
||||
actual_source = sample["frame"] - user.frame_start + user.frame_offset
|
||||
assert actual_source == sample["sourceFrame"], (actual_source, sample["sourceFrame"])
|
||||
assert user.use_auto_refresh
|
||||
|
||||
print("EFFECTCRAFT_BLENDER_ORACLE_OK")
|
||||
@@ -431,12 +431,13 @@ impl<'a> Cx<'a> {
|
||||
fn crop_resize(&self, img: Image) -> Image {
|
||||
let om = self.job.output;
|
||||
let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0));
|
||||
// frame_size applies crop itself: use the original frame and the same scale as edges.
|
||||
let (w, h) = om.frame_size(img.width, img.height, scale);
|
||||
let (t, l, b, r) = om.crop.edges(img.width, img.height, scale);
|
||||
let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) };
|
||||
if !om.resize.enabled {
|
||||
return img;
|
||||
}
|
||||
let (w, h) = om.frame_size(img.width, img.height, 1.0);
|
||||
if (w, h) == (img.width, img.height) {
|
||||
return img;
|
||||
}
|
||||
@@ -1062,6 +1063,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440.
|
||||
#[test]
|
||||
fn crop_resize_applies_margins_once() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
let output = OutputModule {
|
||||
crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() },
|
||||
resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440));
|
||||
let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0);
|
||||
assert_eq!((img.width, img.height), (1280, 1440));
|
||||
assert_eq!(img.data.len(), 1280 * 1440);
|
||||
}
|
||||
|
||||
/// Apply output-module geometry to a synthetic frame without rendering or writing files.
|
||||
fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image {
|
||||
use effectcraft_project::render_queue::RenderSettings;
|
||||
let project = Project::default();
|
||||
let settings = RenderSettings { resolution: scale, ..Default::default() };
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &effectcraft_render::NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: effectcraft_project::ItemId(0),
|
||||
settings: &settings,
|
||||
output,
|
||||
path: "unused.png",
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: Default::default(),
|
||||
};
|
||||
Cx::new(&job).crop_resize(img)
|
||||
}
|
||||
|
||||
/// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities.
|
||||
#[test]
|
||||
fn crop_resize_dimensions_match_the_output_module() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() };
|
||||
let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() };
|
||||
let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() };
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality };
|
||||
for (crop, resize, scale, expected) in [
|
||||
(Crop::default(), Resize::default(), 1.0, (8, 6)),
|
||||
(edges, Resize::default(), 1.0, (4, 6)),
|
||||
(Crop::default(), resize(4), 1.0, (4, 3)),
|
||||
(edges, resize(8), 1.0, (8, 12)),
|
||||
(padding, resize(5), 1.0, (5, 4)),
|
||||
(roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)),
|
||||
(roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)),
|
||||
(edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)),
|
||||
] {
|
||||
let output = OutputModule { crop, resize, ..Default::default() };
|
||||
assert_eq!(output.frame_size(8, 6, scale), expected);
|
||||
let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale);
|
||||
assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}");
|
||||
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes.
|
||||
#[test]
|
||||
fn crop_resize_zero_and_extreme_sizes_stay_bounded() {
|
||||
use effectcraft_project::render_queue::{Crop, OutputModule, Resize};
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
for (size, crop, width, height, expected) in [
|
||||
((8, 6), Crop::default(), 0, 0, (1, 1)),
|
||||
((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)),
|
||||
((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)),
|
||||
((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)),
|
||||
((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)),
|
||||
] {
|
||||
let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() };
|
||||
let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0);
|
||||
assert_eq!((img.width, img.height), expected);
|
||||
assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize);
|
||||
assert!(img.data.iter().all(|p| *p == [0.0; 4]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_pad() {
|
||||
let mut img = Image::new(4, 3);
|
||||
|
||||
@@ -61,7 +61,7 @@ fn project() -> (Project, ItemId) {
|
||||
}
|
||||
box_.blend_mode = BlendMode::Screen;
|
||||
|
||||
comp.layers = vec![box_, ring, solid];
|
||||
comp.layers = vec![box_, ring, solid].into();
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
@@ -94,7 +94,9 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec<u8> {
|
||||
#[test]
|
||||
fn h264_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4");
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_180, 8_728_430_532_616_484_528), "H.264 MP4");
|
||||
// FilmCraft 09aaf9b's encoder lists its settings in the first frame's user-data SEI (211
|
||||
// bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin.
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_341, 17_655_680_090_264_218_914), "H.264 MP4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -174,6 +174,31 @@ fn crop_roi_and_resize() {
|
||||
assert_eq!((z.width(), z.height()), (32, 24));
|
||||
}
|
||||
|
||||
/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel.
|
||||
/// Compare against crop-only output for padding and scaled ROIs with both resize qualities.
|
||||
#[test]
|
||||
fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
for (crop, scale, expected) in [
|
||||
(Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)),
|
||||
(Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)),
|
||||
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)),
|
||||
(Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)),
|
||||
] {
|
||||
let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) };
|
||||
let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) };
|
||||
let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0);
|
||||
assert_eq!(reference.dimensions(), expected);
|
||||
assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content");
|
||||
for quality in [ResizeQuality::Low, ResizeQuality::High] {
|
||||
output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality };
|
||||
let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0);
|
||||
assert_eq!(actual.dimensions(), expected);
|
||||
assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_only_and_premultiplied() {
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
||||
|
||||
@@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
}
|
||||
|
||||
fn decode(frame: &[u8]) -> filmcraft_vp9::Picture {
|
||||
let mut d = Decoder::new();
|
||||
let mut d = Decoder::with_threads(1);
|
||||
let mut pics = d.decode(frame, 0).expect("decode");
|
||||
assert_eq!(pics.len(), 1);
|
||||
pics.remove(0)
|
||||
@@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec<u8>; 3] {
|
||||
fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) {
|
||||
let (w, h) = (cfg.width as usize, cfg.height as usize);
|
||||
let mut e = Vp9Encoder::new(cfg);
|
||||
let mut d = Decoder::new();
|
||||
let mut d = Decoder::with_threads(1);
|
||||
let (mut bytes, mut db) = (0usize, 0.0);
|
||||
for t in 0..n {
|
||||
let (y, u, v) = moving(w, h, t, kind);
|
||||
@@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() {
|
||||
}
|
||||
let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") };
|
||||
// The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg.
|
||||
let mut d = Decoder::new();
|
||||
let mut d = Decoder::with_threads(1);
|
||||
for (t, (f, r)) in frames.iter().zip(&recon).enumerate() {
|
||||
let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}"));
|
||||
assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}");
|
||||
|
||||
+83
-14
@@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
__seed = 0;
|
||||
__timeless = false;
|
||||
__projectInfo = null;
|
||||
__layersTrim();
|
||||
}
|
||||
|
||||
function __finish(r) {
|
||||
@@ -44,7 +45,7 @@ function __finish(r) {
|
||||
|
||||
function __result(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (typeof r === 'function' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
@@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) {
|
||||
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
|
||||
n = Math.floor(n);
|
||||
var info = __h(__ANCESTOR, c, l, path, n);
|
||||
if (info === 'layer') return new Layer(c, l);
|
||||
if (info === 'layer') return __layer(c, l);
|
||||
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
|
||||
return __mkGroup(c, l, info);
|
||||
}
|
||||
@@ -518,10 +519,10 @@ Comp.prototype = {
|
||||
return __err('Composition "' + this.name + '" has no active camera');
|
||||
},
|
||||
layer: function (a, rel) {
|
||||
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
var id = __h(__LAYER, this.__c, a);
|
||||
if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range');
|
||||
return new Layer(this.__c, id);
|
||||
return __layer(this.__c, id);
|
||||
},
|
||||
toString: function () { return '[object Comp]'; },
|
||||
};
|
||||
@@ -596,7 +597,55 @@ var thisProject = {
|
||||
|
||||
// Layer -----------------------------------------------------------------------------------------
|
||||
|
||||
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
|
||||
// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's
|
||||
// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English
|
||||
// off). Callable like a group (`__mkGroup`), with the Layer methods.
|
||||
function Layer(c, l) {
|
||||
var f = function (k) { return f.__group(k); };
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, Layer.prototype);
|
||||
// Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete
|
||||
// a field or leave its own on it. Its info is private to `__info`, which reads it again in an
|
||||
// evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so
|
||||
// freezing the layer can't keep its info either.
|
||||
var info = null, read = null;
|
||||
Object.defineProperty(f, '__c', { value: c });
|
||||
Object.defineProperty(f, '__l', { value: l });
|
||||
Object.defineProperty(f, '__info', {
|
||||
get: function () {
|
||||
if (read !== __S) { info = __h(__LINFO, c, l); read = __S; }
|
||||
return info;
|
||||
},
|
||||
});
|
||||
Object.preventExtensions(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain
|
||||
// object, so it is kept, and its info (`__info`) is read again in each evaluation.
|
||||
// The kept layers are out of an expression's reach (their maps have no prototype, so a change to
|
||||
// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once
|
||||
// `max` are kept, the layers after them aren't, and the next evaluation starts over
|
||||
// (`__layersTrim`, so a layer stays the same object for a whole evaluation).
|
||||
var __layer, __layersTrim;
|
||||
(function () {
|
||||
var layers = Object.create(null), kept = 0, max = 4096;
|
||||
__layer = function (c, l) {
|
||||
// By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves.
|
||||
var m = layers[c], f = m && m[l];
|
||||
if (f) return f;
|
||||
f = new Layer(c, l);
|
||||
if (kept < max) {
|
||||
(m || (layers[c] = Object.create(null)))[l] = f;
|
||||
kept++;
|
||||
}
|
||||
return f;
|
||||
};
|
||||
__layersTrim = function () {
|
||||
if (kept >= max) { layers = Object.create(null); kept = 0; }
|
||||
};
|
||||
})();
|
||||
|
||||
function __m3(m, p) {
|
||||
var x = p[0] || 0, y = p[1] || 0;
|
||||
@@ -620,7 +669,6 @@ function __m4(m, p, vec) {
|
||||
|
||||
Layer.prototype = {
|
||||
__isLayer: true,
|
||||
get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); },
|
||||
get name() { return this.__info[0]; },
|
||||
get index() { return this.__info[1]; },
|
||||
get inPoint() { return this.__info[2]; },
|
||||
@@ -632,7 +680,7 @@ Layer.prototype = {
|
||||
get parent() {
|
||||
var p = this.__info[7];
|
||||
if (p === null) __err('Layer "' + this.name + '" has no parent');
|
||||
return new Layer(this.__c, p);
|
||||
return __layer(this.__c, p);
|
||||
},
|
||||
get hasVideo() { return this.__info[8]; },
|
||||
get enabled() { return this.__info[11]; },
|
||||
@@ -804,7 +852,7 @@ Prop.prototype = {
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
__wrap: function (v) {
|
||||
var kind = this.__info[2];
|
||||
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
|
||||
if (kind === 'layer') return v === null ? null : __layer(this.__c, v);
|
||||
if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]);
|
||||
return v;
|
||||
},
|
||||
@@ -834,7 +882,7 @@ Prop.prototype = {
|
||||
for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
|
||||
return this.key(best + 1);
|
||||
},
|
||||
__seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; },
|
||||
__seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; },
|
||||
wiggle: function (freq, amp, octaves, ampMult, t) {
|
||||
if (octaves === undefined) octaves = 1;
|
||||
if (ampMult === undefined) ampMult = 0.5;
|
||||
@@ -903,8 +951,9 @@ var __propHandler = {
|
||||
var v = t.value;
|
||||
if (v === null || v === undefined) return undefined;
|
||||
var x = v[k];
|
||||
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
|
||||
if (x?.__isGroup) return x;
|
||||
// Property groups and layers are callable: hand them over as they are, not bound like methods
|
||||
// (#809).
|
||||
if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x;
|
||||
return typeof x === 'function' ? x.bind(v) : x;
|
||||
},
|
||||
};
|
||||
@@ -1082,18 +1131,18 @@ Markers.prototype = {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're
|
||||
// accessors on the global object; assigning one (`var width = 10`) shadows it for the current
|
||||
// evaluation only.
|
||||
// evaluation only. They can't be deleted or redefined, so an expression can't take one away from
|
||||
// the evaluations after it.
|
||||
|
||||
function __global(name, get) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
get: function () { return name in __S.vars ? __S.vars[name] : get(); },
|
||||
set: function (v) { __S.vars[name] = v; },
|
||||
});
|
||||
}
|
||||
|
||||
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); });
|
||||
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
|
||||
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
|
||||
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
|
||||
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
|
||||
@@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
|
||||
delete globalThis.Proxy;
|
||||
delete globalThis.Reflect;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The runtime runs this file once and keeps it for the thread's later evaluations, so an
|
||||
// expression can't change the object model or the internals: the prototypes are frozen, and the
|
||||
// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is
|
||||
// set again by `__begin` in each evaluation.
|
||||
|
||||
[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) {
|
||||
Object.freeze(f.prototype);
|
||||
Object.freeze(f);
|
||||
});
|
||||
[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze);
|
||||
(function () {
|
||||
var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true };
|
||||
var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true };
|
||||
Object.getOwnPropertyNames(globalThis).forEach(function (k) {
|
||||
if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return;
|
||||
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
|
||||
});
|
||||
})();
|
||||
@@ -77,7 +77,7 @@ fn fx() -> Fx {
|
||||
|
||||
let ids = (a.id, b.id, title.id, shape.id);
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers = vec![a, b, title, shape];
|
||||
c.layers = vec![a, b, title, shape].into();
|
||||
c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() });
|
||||
|
||||
let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0));
|
||||
@@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another
|
||||
/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact
|
||||
/// English off (they failed: "not a callable function"). It is still a layer.
|
||||
#[test]
|
||||
fn layers_are_callable_property_groups() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0);
|
||||
assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0);
|
||||
// Its own name, index and methods, and a layer as the result still reads as its index.
|
||||
assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0);
|
||||
assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0);
|
||||
assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")"));
|
||||
}
|
||||
|
||||
/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project
|
||||
/// afresh in each, and an expression's own fields on it don't reach the next one.
|
||||
#[test]
|
||||
fn a_kept_layer_reads_the_project_again() {
|
||||
let mut f = fx();
|
||||
let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length";
|
||||
assert_close!(f.num(first, 0.0), 21.0);
|
||||
f.layer_mut(f.b).name = "Bee".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0);
|
||||
assert_close!(f.num("thisLayer.index", 0.0), 2.0);
|
||||
let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
|
||||
let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0);
|
||||
f.layer_mut(f.a).name = "Ay".into();
|
||||
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
|
||||
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0);
|
||||
}
|
||||
|
||||
/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more
|
||||
/// layers than are kept still sees one object for each layer it had kept, and the evaluations
|
||||
/// after it, which start over, read the right layers.
|
||||
#[test]
|
||||
fn the_kept_layers_are_bounded() {
|
||||
let f = fx();
|
||||
let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)";
|
||||
let many =
|
||||
format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)");
|
||||
assert_close!(f.num(&many, 0.0), 11.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
|
||||
}
|
||||
|
||||
/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under
|
||||
/// the comp's id, before any of its layers is kept, doesn't stop them being kept.
|
||||
#[test]
|
||||
fn the_kept_layers_dont_go_through_object_prototype() {
|
||||
let f = fx();
|
||||
let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0);
|
||||
assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The prelude is kept from one evaluation to the next, so an expression can't change its object
|
||||
/// model or internals for the ones after it.
|
||||
#[test]
|
||||
fn an_expression_cant_change_the_prelude_for_the_next() {
|
||||
let f = fx();
|
||||
let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]";
|
||||
let before = f.ev(f.a, ROT, read, 0.5).unwrap();
|
||||
let attacks = [
|
||||
"Comp.prototype.layer = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer).index = 0",
|
||||
"Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }",
|
||||
"Object.getPrototypeOf(thisLayer.transform.opacity).value = 0",
|
||||
"Comp = function () {}",
|
||||
"Prop = function () {}",
|
||||
"thisProject = null",
|
||||
"__h = function () { return null; }",
|
||||
"__layer = function () { return null; }",
|
||||
"__child = function () {}",
|
||||
"delete __mkProp",
|
||||
"delete thisLayer",
|
||||
"delete thisComp",
|
||||
"delete width",
|
||||
"Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })",
|
||||
];
|
||||
for a in attacks {
|
||||
let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0);
|
||||
assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
@@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() {
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
// The layer it reaches is the same object as `thisLayer`.
|
||||
assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0);
|
||||
let s = staggered();
|
||||
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
}
|
||||
|
||||
+24
-9
@@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option<u64> {
|
||||
/// Checking these before serialization bounds host packing as well as the GPU uploads.
|
||||
fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> {
|
||||
let mut sizes = [0; 7];
|
||||
sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?;
|
||||
sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?;
|
||||
for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() {
|
||||
let size = count.checked_mul(stride)?.max(16);
|
||||
sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?;
|
||||
@@ -164,11 +164,11 @@ mod capability_tests {
|
||||
|
||||
#[test]
|
||||
fn packed_arrays_preflight_exact_strides_and_empty_padding() {
|
||||
assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16]));
|
||||
assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16]));
|
||||
assert!(packed_bytes([0; 6], 159, 16, 160).is_none());
|
||||
assert!(packed_bytes([0; 6], 160, 15, 160).is_none());
|
||||
assert!(packed_bytes([2; 6], 160, 192, 191).is_none());
|
||||
assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16]));
|
||||
assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16]));
|
||||
assert!(packed_bytes([0; 6], 175, 16, 176).is_none());
|
||||
assert!(packed_bytes([0; 6], 176, 15, 176).is_none());
|
||||
assert!(packed_bytes([2; 6], 176, 192, 191).is_none());
|
||||
for index in 0..6 {
|
||||
let mut counts = [0; 6];
|
||||
counts[index] = u64::MAX;
|
||||
@@ -203,7 +203,7 @@ mod capability_tests {
|
||||
};
|
||||
for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] {
|
||||
let actual = pack(&scene).map(|bytes| bytes.len() as u64);
|
||||
assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160));
|
||||
assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
|
||||
globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]);
|
||||
globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]);
|
||||
globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]);
|
||||
let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 });
|
||||
let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo {
|
||||
radiance: 0,
|
||||
mips: 1,
|
||||
irradiance: 0,
|
||||
intensity: 0.0,
|
||||
rotation: 0.0,
|
||||
sun: [0.0; 3],
|
||||
key: 0.0,
|
||||
shadow: -1,
|
||||
shadow_darkness: 0.0,
|
||||
shadow_diffusion: 0.0,
|
||||
});
|
||||
globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]);
|
||||
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
|
||||
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
|
||||
// env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness.
|
||||
globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]);
|
||||
// The environment's dominant direction (its shadow's source), for the shadow bias slope,
|
||||
// and its key fraction — how much of its light the shadow can remove (shade::env_shadow).
|
||||
globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]);
|
||||
let mats = s.materials.iter().flat_map(|m| {
|
||||
let flags = (m.double_sided as u32)
|
||||
| (m.unlit as u32) << 1
|
||||
|
||||
@@ -22,6 +22,20 @@ pub(crate) struct GBuf {
|
||||
}
|
||||
|
||||
impl GBuf {
|
||||
/// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it.
|
||||
pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> {
|
||||
let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) };
|
||||
if !e.g.fits(w, h) {
|
||||
return None;
|
||||
}
|
||||
let out = e.image(w, h);
|
||||
e.copy_into(&self.img, &out, dx, dy);
|
||||
self.img = out;
|
||||
self.offset[0] += dx as f64;
|
||||
self.offset[1] += dy as f64;
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
@@ -167,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
||||
/// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no
|
||||
/// readback. A region the device can't allocate leaves the buffer as it is.
|
||||
fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some(mut b) = self.b.take() else { return };
|
||||
let keep = (b.img.clone(), b.offset);
|
||||
if b.cover(self.e, size).is_none() {
|
||||
(b.img, b.offset) = keep;
|
||||
}
|
||||
self.b = Some(b);
|
||||
}
|
||||
|
||||
/// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one
|
||||
/// copied in. A size the device can't allocate leaves the buffer as it is.
|
||||
fn align(&mut self, size: [f64; 2]) {
|
||||
let Some(b) = self.b.take() else { return };
|
||||
let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((dx, dy, w, h)) if self.e.g.fits(w, h) => {
|
||||
let img = self.e.image(w, h);
|
||||
self.e.copy_into(&b.img, &img, dx, dy);
|
||||
GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale }
|
||||
let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => {
|
||||
let img = self.e.image(b.img.width + l, b.img.height + t);
|
||||
self.e.copy_into(&b.img, &img, l, t);
|
||||
GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale }
|
||||
}
|
||||
_ => b,
|
||||
};
|
||||
@@ -292,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad(e, (s * 3.0).ceil() as u32)?;
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(e, ctx.layer_size)?;
|
||||
}
|
||||
b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment);
|
||||
Some(b)
|
||||
@@ -312,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let repeat = ctx.params.b("repeatEdge");
|
||||
if !repeat && !ctx.adjustment {
|
||||
b.pad(e, (r * it) as u32 + 1)?;
|
||||
} else if repeat && !ctx.adjustment {
|
||||
// The edge it repeats is the layer's, not the painted content's (#1205).
|
||||
b.cover(e, ctx.layer_size)?;
|
||||
}
|
||||
b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment);
|
||||
Some(b)
|
||||
|
||||
@@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(e, amount.abs().ceil() as u32 + 1)?;
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?;
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
|
||||
@@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf
|
||||
}
|
||||
let mut p = Params::default();
|
||||
p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0];
|
||||
p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale);
|
||||
let img = raster(e, items, 1, false, &b.img, None, p, w, h)?;
|
||||
Some(GBuf { img, ..b })
|
||||
}
|
||||
|
||||
+170
-38
@@ -2,12 +2,15 @@
|
||||
// Browser callbacks stay on their worker thread. The workspace's wgpu feature still
|
||||
// requires Send callbacks on non-atomic wasm, and ParticleSim requires Send + Sync.
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Condvar;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(crate) type Error = String;
|
||||
type Done = Box<dyn FnOnce(Result<Vec<u8>, Error>) + Send>;
|
||||
const TIMED_OUT: &str = "GPU readback timed out; context retired";
|
||||
|
||||
struct Entry {
|
||||
deadline: web_time::Instant,
|
||||
@@ -20,9 +23,70 @@ struct State {
|
||||
retired: Option<Error>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
timer_armed: bool,
|
||||
/// The deadline worker is to exit: the context was dropped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
closed: bool,
|
||||
/// How many times the deadline worker woke up (tests: it must not wake while idle).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
worker_wakes: u64,
|
||||
}
|
||||
/// The state and, natively, the condition the deadline worker sleeps on. The worker owns an
|
||||
/// `Arc` of this, not of the [`Readbacks`], so it can block on the condition without keeping
|
||||
/// the context alive; dropping the context closes it.
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
state: Mutex<State>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
wake: Condvar,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Readbacks(Mutex<State>);
|
||||
pub(crate) struct Readbacks(Arc<Inner>);
|
||||
|
||||
/// Retire the context when a deadline has passed: the entries to fail, or `None` when none
|
||||
/// is due (or it is retired already). Decides and drains under the lock the caller holds.
|
||||
fn expire_locked(s: &mut State, now: web_time::Instant) -> Option<Vec<Entry>> {
|
||||
// Decide and drain atomically: a cancelled old deadline must not retire
|
||||
// a newer request inserted between the deadline check and retirement.
|
||||
if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) {
|
||||
return None;
|
||||
}
|
||||
s.retired = Some(TIMED_OUT.to_string());
|
||||
Some(std::mem::take(&mut s.pending).into_values().collect())
|
||||
}
|
||||
|
||||
/// The deadline worker. It sleeps while nothing is pending, and otherwise exactly until the
|
||||
/// earliest deadline or until what is pending changes, so an idle context costs no wakeups
|
||||
/// (a 100 ms polling loop here was ten wakeups a second for the life of the app). When a
|
||||
/// deadline has passed it retires the context and fails every pending readback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_deadlines(inner: &Inner) {
|
||||
let mut s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
loop {
|
||||
if s.closed {
|
||||
return;
|
||||
}
|
||||
let now = web_time::Instant::now();
|
||||
match s.pending.values().map(|e| e.deadline).min() {
|
||||
None => s = inner.wake.wait(s).unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
Some(deadline) if deadline > now => {
|
||||
s = inner.wake.wait_timeout(s, deadline - now).unwrap_or_else(std::sync::PoisonError::into_inner).0;
|
||||
}
|
||||
Some(_) => {
|
||||
let expired = expire_locked(&mut s, now);
|
||||
drop(s);
|
||||
for entry in expired.into_iter().flatten() {
|
||||
notify(entry.done, Err(TIMED_OUT.into()));
|
||||
}
|
||||
s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
s.worker_wakes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) struct Ticket {
|
||||
owner: Weak<Readbacks>,
|
||||
id: u64,
|
||||
@@ -50,23 +114,24 @@ impl Readbacks {
|
||||
let me = Arc::new(Self::default());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let weak = Arc::downgrade(&me);
|
||||
let inner = Arc::clone(&me.0);
|
||||
std::thread::Builder::new()
|
||||
.name("gpu-readback-deadlines".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let Some(me) = weak.upgrade() else { break };
|
||||
me.expire(web_time::Instant::now());
|
||||
}
|
||||
})
|
||||
.spawn(move || run_deadlines(&inner))
|
||||
.map_err(|e| format!("starting GPU readback deadline worker: {e}"))?;
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// Have the deadline worker look at what is pending again (natively; the browser's timer
|
||||
/// re-arms itself).
|
||||
fn poke(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.0.wake.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<(), Error> {
|
||||
let s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match &s.retired {
|
||||
Some(e) => Err(e.clone()),
|
||||
None => Ok(()),
|
||||
@@ -76,7 +141,7 @@ impl Readbacks {
|
||||
pub(crate) fn start(self: &Arc<Self>, done: impl FnOnce(Result<Vec<u8>, Error>) + wgpu::WasmNotSend + 'static) -> Result<Ticket, Error> {
|
||||
let mut done: Option<Done> = Some(Box::new(done));
|
||||
let result = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(e) = &s.retired {
|
||||
Err(e.clone())
|
||||
} else if s.pending.len() >= 256 {
|
||||
@@ -96,8 +161,10 @@ impl Readbacks {
|
||||
{
|
||||
notify(done, Err(e.clone()));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if result.is_ok() {
|
||||
// The worker may be asleep with nothing pending: this is its new earliest deadline.
|
||||
self.poke();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.arm_timer();
|
||||
}
|
||||
result
|
||||
@@ -106,7 +173,7 @@ impl Readbacks {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn arm_timer(self: &Arc<Self>) {
|
||||
let delay = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if s.timer_armed || s.retired.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -123,13 +190,14 @@ impl Readbacks {
|
||||
|
||||
pub(crate) fn retire(&self, error: Error) {
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if s.retired.is_some() {
|
||||
return;
|
||||
}
|
||||
s.retired = Some(error.clone());
|
||||
std::mem::take(&mut s.pending)
|
||||
};
|
||||
self.poke();
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
}
|
||||
@@ -137,34 +205,49 @@ impl Readbacks {
|
||||
|
||||
pub(crate) fn cancel_pending(&self, error: Error) {
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
std::mem::take(&mut s.pending)
|
||||
};
|
||||
self.poke();
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire the context when a deadline has passed (the browser's timer and tests; natively
|
||||
/// the deadline worker does this itself).
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
fn expire(&self, now: web_time::Instant) {
|
||||
let error = "GPU readback timed out; context retired".to_string();
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Decide and drain atomically: a cancelled old deadline must not retire
|
||||
// a newer request inserted between the deadline check and retirement.
|
||||
if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) {
|
||||
return;
|
||||
}
|
||||
s.retired = Some(error.clone());
|
||||
std::mem::take(&mut s.pending)
|
||||
let expired = {
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
expire_locked(&mut s, now)
|
||||
};
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
for entry in expired.into_iter().flatten() {
|
||||
notify(entry.done, Err(TIMED_OUT.into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move every pending deadline to `deadline` and wake the worker, as a test's stand-in for
|
||||
/// waiting out the production timeout.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
fn move_deadlines_for_test(&self, deadline: web_time::Instant) {
|
||||
{
|
||||
let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for entry in s.pending.values_mut() {
|
||||
entry.deadline = deadline;
|
||||
}
|
||||
}
|
||||
self.poke();
|
||||
}
|
||||
}
|
||||
impl Drop for Readbacks {
|
||||
fn drop(&mut self) {
|
||||
self.retire("GPU readback context dropped".into());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).closed = true;
|
||||
self.0.wake.notify_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,12 +277,14 @@ impl Layout {
|
||||
|
||||
impl Ticket {
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.owner.upgrade().is_some_and(|me| me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id))
|
||||
self.owner.upgrade().is_some_and(|me| me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id))
|
||||
}
|
||||
pub(crate) fn complete(self, result: Result<Vec<u8>, Error>) {
|
||||
let Some(me) = self.owner.upgrade() else { return };
|
||||
let entry = me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id);
|
||||
let entry = me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id);
|
||||
if let Some(entry) = entry {
|
||||
// Its deadline is gone: the worker sleeps until the next one, or until something new.
|
||||
me.poke();
|
||||
notify(entry.done, result);
|
||||
}
|
||||
}
|
||||
@@ -215,7 +300,7 @@ fn schedule(me: Weak<Readbacks>, delay: Duration) -> Result<(), Error> {
|
||||
.map_err(|_| "GPU deadline timer unavailable".to_string())?;
|
||||
let callback = Closure::once(move || {
|
||||
if let Some(me) = me.upgrade() {
|
||||
me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false;
|
||||
me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false;
|
||||
me.expire(web_time::Instant::now());
|
||||
// If the earliest request completed, arm for the next live deadline.
|
||||
me.arm_timer();
|
||||
@@ -377,7 +462,7 @@ mod tests {
|
||||
assert_eq!(deferred.in_flight(), 0);
|
||||
assert_eq!(wakes.0.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(settled.as_mut().poll(&mut Context::from_waker(&waker)), Poll::Ready(())));
|
||||
assert!(me.0.lock().unwrap().pending.is_empty());
|
||||
assert!(me.0.state.lock().unwrap().pending.is_empty());
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
|
||||
@@ -390,17 +475,64 @@ mod tests {
|
||||
tx2.send(()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let mut state = me.0.lock().unwrap();
|
||||
for entry in state.pending.values_mut() {
|
||||
entry.deadline = web_time::Instant::now();
|
||||
}
|
||||
}
|
||||
me.move_deadlines_for_test(web_time::Instant::now());
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert!(me.0.lock().unwrap().pending.is_empty());
|
||||
assert!(me.0.state.lock().unwrap().pending.is_empty());
|
||||
assert!(me.check().is_err());
|
||||
}
|
||||
|
||||
/// With nothing pending the deadline worker sleeps: no periodic wakeups (it used to poll
|
||||
/// every 100 ms for the life of the app, ten wakeups a second while idle).
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn an_idle_deadline_worker_does_not_wake_up() {
|
||||
let me = Readbacks::new().unwrap();
|
||||
std::thread::sleep(Duration::from_millis(350));
|
||||
let wakes = me.0.state.lock().unwrap().worker_wakes;
|
||||
assert!(wakes <= 1, "the worker woke {wakes} times while nothing was pending");
|
||||
// A readback that completes in time: the worker is told, and goes back to sleep.
|
||||
let ticket = me.start(|r| assert!(r.is_ok())).unwrap();
|
||||
ticket.complete(Ok(vec![1]));
|
||||
std::thread::sleep(Duration::from_millis(350));
|
||||
let wakes = me.0.state.lock().unwrap().worker_wakes;
|
||||
assert!(wakes <= 3, "the worker woke {wakes} times for one completed readback");
|
||||
assert!(me.check().is_ok());
|
||||
}
|
||||
|
||||
/// A deadline that passes wakes the worker exactly then, without the old polling loop.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn a_pending_deadline_wakes_the_worker_when_it_is_due() {
|
||||
let me = Readbacks::new().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _ticket = me
|
||||
.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
tx.send(web_time::Instant::now()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
let due = web_time::Instant::now() + Duration::from_millis(200);
|
||||
me.move_deadlines_for_test(due);
|
||||
let fired = rx.recv_timeout(Duration::from_secs(5)).expect("the deadline fires");
|
||||
assert!(fired >= due, "fired {:?} early", due - fired);
|
||||
assert!(fired < due + Duration::from_secs(2), "fired {:?} late", fired - due);
|
||||
assert!(me.check().is_err());
|
||||
}
|
||||
|
||||
/// Dropping the last handle lets the worker exit: it holds the state, not the context.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dropping_the_context_stops_the_deadline_worker() {
|
||||
let me = Readbacks::new().unwrap();
|
||||
let inner = Arc::downgrade(&me.0);
|
||||
drop(me);
|
||||
let t0 = web_time::Instant::now();
|
||||
while inner.upgrade().is_some() {
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still holding the state");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_callback_times_out_once_and_rejects_late_success() {
|
||||
let me = Arc::new(Readbacks::default());
|
||||
|
||||
@@ -14,8 +14,10 @@ struct Globals {
|
||||
ambient: vec4<f32>,
|
||||
// radiance first texture, mip count, irradiance texture, intensity
|
||||
env: vec4<f32>,
|
||||
// rotation, any lighting (0/1), -, -
|
||||
// rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness
|
||||
env2: vec4<f32>,
|
||||
// the environment's dominant direction (its shadow's source)
|
||||
env_sun: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> g: Globals;
|
||||
@@ -339,6 +341,18 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32,
|
||||
return 1.0 - darkness * (1.0 - f);
|
||||
}
|
||||
|
||||
// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the
|
||||
// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so
|
||||
// the shadow stays partial the way After Effects renders an area-light shadow.
|
||||
fn env_shadow(p: vec3<f32>, ng: vec3<f32>, n: vec3<f32>) -> f32 {
|
||||
if (g.env2.z < 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0);
|
||||
let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l);
|
||||
return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f);
|
||||
}
|
||||
|
||||
fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3<f32>, to_l: vec3<f32>, dist: f32) -> f32 {
|
||||
var k = 1.0;
|
||||
if (falloff == 1u && kind != 0u) {
|
||||
@@ -425,6 +439,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32
|
||||
}
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));
|
||||
}
|
||||
// The Environment light's shadow counts too (it reaches every surface: no falloff).
|
||||
if (g.ambient.w > 0.5) {
|
||||
amount = max(amount, 1.0 - env_shadow(pos, n, n));
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
@@ -564,7 +582,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let f0 = mix(vec3<f32>(0.04 * spec_k), rgb, metallic);
|
||||
let ab = env_brdf(n_v, rough);
|
||||
let fs = f0 * ab.x + vec3<f32>(ab.y);
|
||||
acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao;
|
||||
var esh = 1.0;
|
||||
if (receives_shadows) {
|
||||
esh = env_shadow(i.pos, ng, n);
|
||||
}
|
||||
acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh;
|
||||
}
|
||||
let c = acc + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
|
||||
@@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
return FxmShade(true, vec4<f32>(rgb * a, a));
|
||||
}
|
||||
|
||||
// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type.
|
||||
// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer
|
||||
// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within.
|
||||
fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
let qx = data[at + 1u];
|
||||
let qy = data[at + 2u];
|
||||
@@ -180,7 +181,9 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
|
||||
}
|
||||
let k = select(-0.7, 1.6, P.u[1].y != 0u);
|
||||
let nz = sqrt(max(1.0 - d2, 0.0));
|
||||
let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
|
||||
let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z);
|
||||
let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w);
|
||||
let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy));
|
||||
var c = s.xyz;
|
||||
if (s.w <= 0.0) {
|
||||
c = vec3<f32>(0.6, 0.7, 0.8);
|
||||
|
||||
@@ -498,7 +498,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() {
|
||||
b.blend_mode = BlendMode::Difference;
|
||||
(a, b)
|
||||
};
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into();
|
||||
let bg = s.footage(97, 61);
|
||||
s.push(bg);
|
||||
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
|
||||
@@ -83,7 +83,7 @@ fn track_mattes_limit_gpu_adjustments_like_the_cpu() {
|
||||
set(&mut matte, "transform/rotation", Value::Scalar(10.0));
|
||||
let mid = s.push(matte);
|
||||
let c = s.p.comp_mut(s.cid).unwrap();
|
||||
let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap();
|
||||
let adj = c.layers.find_mut(|l| l.switches.adjustment).unwrap();
|
||||
adj.track_matte = Some(TrackMatte { layer: mid, kind });
|
||||
check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most
|
||||
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_keyframe::{Gradient, Value};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid};
|
||||
use effectcraft_render::three_d::adv::{self, Scene};
|
||||
@@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
|
||||
}
|
||||
|
||||
/// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,
|
||||
/// an environment light, a semi-transparent textured card.
|
||||
/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent
|
||||
/// textured card.
|
||||
pub(crate) fn scene_project() -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
@@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) {
|
||||
set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(30.0));
|
||||
});
|
||||
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||||
l.environment = true;
|
||||
// The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky,
|
||||
// shadows the scene from above).
|
||||
add(&mut p, cid, LayerSource::Shape, |l| {
|
||||
l.switches.three_d = true;
|
||||
let mut next = 10_000u64;
|
||||
let ids = &mut build::Ids(&mut next);
|
||||
let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0);
|
||||
let gfill = build::shape_gradient_fill(
|
||||
ids,
|
||||
false,
|
||||
[0.0, -80.0],
|
||||
[0.0, 80.0],
|
||||
Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
|
||||
);
|
||||
let g = build::shape_group(ids, "Sky", vec![rect, gfill]);
|
||||
l.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
l.environment = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
|
||||
set(l, "lightOptions/intensity", Value::Scalar(50.0));
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0)));
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| {
|
||||
set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0]));
|
||||
set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0]));
|
||||
@@ -144,6 +162,8 @@ fn gpu_matches_cpu_lit_scene() {
|
||||
let (p, cid) = scene_project();
|
||||
let s = scene(&p, cid);
|
||||
assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32);
|
||||
// The Environment light casts its shadow (the sky gradient's dominant direction).
|
||||
assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built");
|
||||
compare(&s, &g);
|
||||
}
|
||||
|
||||
@@ -190,7 +210,7 @@ fn gpu_matches_cpu_wide_penumbrae() {
|
||||
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0));
|
||||
let s = scene(&p, cid);
|
||||
let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster"));
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers[..2] {
|
||||
for l in p.comp_mut(cid).unwrap().layers.iter_mut().take(2) {
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(false));
|
||||
}
|
||||
let unshadowed = adv::raster::render(&scene(&p, cid));
|
||||
@@ -260,7 +280,7 @@ pub(crate) fn dof_mb_project() -> (Project, ItemId) {
|
||||
c.enable_motion_blur = true;
|
||||
c.motion_blur_samples = 6;
|
||||
// The sphere moves 40 px per frame.
|
||||
let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
|
||||
let sphere = c.layers.find_mut(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
|
||||
sphere.switches.motion_blur = true;
|
||||
sphere.props.prop_mut("transform/position").unwrap().keys = vec![
|
||||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])),
|
||||
@@ -429,7 +449,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
|
||||
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
|
||||
// A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU).
|
||||
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||||
p.comp_mut(cid).unwrap().layers.find_mut(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||||
effectcraft_color::BlendMode::Screen;
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none());
|
||||
@@ -550,12 +570,12 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
||||
});
|
||||
}
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
let env = c.layers.iter_mut().find(|l| l.environment).unwrap();
|
||||
let env = c.layers.find_mut(|l| l.environment).unwrap();
|
||||
env.props.sub_mut("effects").unwrap().children.push(ramp.into());
|
||||
let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect();
|
||||
let top = c.layers[0].id;
|
||||
fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer {
|
||||
c.layers.iter_mut().find(|l| l.id == id).unwrap()
|
||||
c.layers.find_mut(|l| l.id == id).unwrap()
|
||||
}
|
||||
match case {
|
||||
0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen,
|
||||
@@ -573,7 +593,7 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
||||
}
|
||||
_ => {
|
||||
// An environment background behind the run, and the 2D track matte.
|
||||
c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true;
|
||||
c.layers.find_mut(|l| l.environment).unwrap().environment_background = true;
|
||||
by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ fn echo_and_posterize_time() {
|
||||
pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))];
|
||||
let rot = a.props.prop_mut("transform/rotation").unwrap();
|
||||
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a].into();
|
||||
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
s.effect(&mut l, id, &vals);
|
||||
set(&mut l, "transform/rotation", Value::Scalar(8.0));
|
||||
|
||||
@@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() {
|
||||
case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0);
|
||||
}
|
||||
|
||||
/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the
|
||||
/// refraction samples to the layer rectangle, not to the buffer (#2024).
|
||||
#[test]
|
||||
fn bubbles_on_padded_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap();
|
||||
let size = [70.0, 44.0];
|
||||
for reflection in [0, 1] {
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
||||
for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] {
|
||||
params.values.insert(k.to_string(), v);
|
||||
}
|
||||
for scale in [1.0, 0.5] {
|
||||
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
|
||||
let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
|
||||
buf.pad(6);
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let out =
|
||||
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry");
|
||||
let d = diff(&cpu.img, &out.img, 1e-3);
|
||||
assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hair_wave_world_foam() {
|
||||
case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0);
|
||||
|
||||
@@ -40,7 +40,7 @@ fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) {
|
||||
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))];
|
||||
let rot = a.props.prop_mut("transform/rotation").unwrap();
|
||||
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a].into();
|
||||
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
s.effect(&mut l, id, &vals);
|
||||
set(&mut l, "transform/rotation", Value::Scalar(8.0));
|
||||
|
||||
@@ -164,7 +164,7 @@ fn color_stabilizer() {
|
||||
pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))];
|
||||
let op = a.props.prop_mut("transform/opacity").unwrap();
|
||||
op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![a].into();
|
||||
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
|
||||
let vals = [
|
||||
("stabilize", e(mode)),
|
||||
|
||||
@@ -177,7 +177,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() {
|
||||
let mid = s.push(m);
|
||||
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
|
||||
layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma });
|
||||
if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") {
|
||||
if let Some(far) = layers.find_mut(|l| l.switches.three_d && l.name == "Pattern") {
|
||||
far.preserve_transparency = true;
|
||||
}
|
||||
// Move the matte below the bottom so the 3D run stays one run.
|
||||
@@ -203,7 +203,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() {
|
||||
a.switches.three_d = true;
|
||||
set(&mut a, "transform/rotationX", Value::Scalar(40.0));
|
||||
let b = s.solid([0.2, 0.8, 0.4], 30, 20);
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a];
|
||||
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into();
|
||||
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None);
|
||||
pre.switches.collapse = true;
|
||||
pre.switches.three_d = true;
|
||||
@@ -299,7 +299,7 @@ fn two_d_layer_with_a_three_d_track_matte() {
|
||||
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
|
||||
let fi = layers.iter().position(|l| l.id == fid).unwrap();
|
||||
layers[fi].track_matte = Some(TrackMatte { layer: mid, kind });
|
||||
if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) {
|
||||
if let Some(ml) = layers.find_mut(|l| l.id == mid) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
|
||||
|
||||
@@ -146,27 +146,58 @@ fn a_file_still_being_written_is_tried_again() {
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE));
|
||||
}
|
||||
|
||||
/// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app
|
||||
/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`];
|
||||
/// while nothing changes it hands over nothing (so the interface is never woken for it).
|
||||
#[test]
|
||||
fn background_scan_reloads_changed_footage() {
|
||||
let path = tmp("scan.psd");
|
||||
fn the_watcher_notices_a_footage_file_changed_on_disk() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
let path = tmp("watched.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _, cid) = flat_in_comp(&path);
|
||||
let scan = |s: &mut Session| {
|
||||
footage_reload::start_scan(s);
|
||||
let t0 = std::time::Instant::now();
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&wakes);
|
||||
let fast = Duration::from_millis(20);
|
||||
let mut w = footage_reload::Watcher::start_with(
|
||||
s.services.clone(),
|
||||
move || {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
},
|
||||
fast,
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.unwrap();
|
||||
w.set_focused(true);
|
||||
w.set_watched(footage_reload::watched(&s));
|
||||
// Report `n`: the worker stores the stamps and then wakes the interface, so wait for the
|
||||
// wake too, not only for the stamps.
|
||||
let next = |w: &footage_reload::Watcher, n: usize| {
|
||||
let t0 = Instant::now();
|
||||
loop {
|
||||
if let Some(r) = footage_reload::poll_scan(s) {
|
||||
return r.unwrap();
|
||||
if wakes.load(Ordering::SeqCst) >= n
|
||||
&& let Some(stamps) = w.take()
|
||||
{
|
||||
return stamps;
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "scan never finished");
|
||||
std::thread::yield_now();
|
||||
assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
};
|
||||
assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0}));
|
||||
// First look: remembered, nothing to reload.
|
||||
assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 1)).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 1);
|
||||
std::thread::sleep(fast * 15);
|
||||
assert!(w.take().is_none(), "an unchanged file is never reported");
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface");
|
||||
// Saved in another app: reported once, reloaded, shown.
|
||||
save(&path, &flat(GREEN, true));
|
||||
assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1}));
|
||||
assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 2)).unwrap(), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), GREEN));
|
||||
assert!(s.footage_scan.is_none());
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 2);
|
||||
std::thread::sleep(fast * 10);
|
||||
assert!(w.take().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId {
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: None,
|
||||
audio_streams: Vec::new(),
|
||||
container: String::new(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
|
||||
@@ -509,7 +509,7 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
|
||||
assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]);
|
||||
assert_eq!(c.layers[0].id.0, sid);
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
|
||||
for l in &c.layers[1..3] {
|
||||
for l in c.layers.iter().skip(1).take(2) {
|
||||
assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name);
|
||||
assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. }));
|
||||
}
|
||||
|
||||
@@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat
|
||||
return Some(format!("blend mode {}", l.blend_mode.label()));
|
||||
}
|
||||
// Premiere's Basic Motion in the interchange formats has one (uniform) scale.
|
||||
if let Some(s) = l.transform().and_then(|t| t.get("scale"))
|
||||
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
})
|
||||
{
|
||||
if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
|
||||
return Some("non-uniform scale".into());
|
||||
}
|
||||
if l.preserve_transparency {
|
||||
@@ -581,10 +576,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(p) = tr.get("scale") {
|
||||
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() < 1e-9
|
||||
});
|
||||
let uniform = !non_uniform_scale(p);
|
||||
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
|
||||
if !uniform {
|
||||
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
|
||||
@@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
|
||||
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// Whether a scale property is non-uniform at some point: active key values, or the static
|
||||
/// value when every key is muted (muted keys are not exported, #1894).
|
||||
fn non_uniform_scale(p: &Property) -> bool {
|
||||
let non_uniform = |v: &Value| {
|
||||
let a = v.as_vec3();
|
||||
(a[0] - a[1]).abs() > 1e-9
|
||||
};
|
||||
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
|
||||
}
|
||||
|
||||
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
|
||||
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
|
||||
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
|
||||
@@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
|
||||
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
|
||||
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
|
||||
let mut out = fp::Param::new(conv(&p.value));
|
||||
for k in &p.keys {
|
||||
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
|
||||
// the exported animation matches what the renderer shows (#1894).
|
||||
for k in p.keys.iter().filter(|k| !k.muted) {
|
||||
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
|
||||
fk.interp = match k.out_interp {
|
||||
Interp::Linear => fp::Interpolation::Linear,
|
||||
@@ -684,8 +688,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option<VideoSp
|
||||
color: Default::default(),
|
||||
has_alpha,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: audio.then(|| AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }),
|
||||
audio_streams: audio
|
||||
.then(|| AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
container: String::new(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
|
||||
@@ -276,7 +276,7 @@ impl Importer<'_> {
|
||||
MediaKind::AudioOnly => FootageKind::Audio,
|
||||
MediaKind::Still => FootageKind::Still,
|
||||
MediaKind::ImageSequence => FootageKind::Sequence,
|
||||
_ if v.is_none() && info.audio.is_some() => FootageKind::Audio,
|
||||
_ if v.is_none() && info.has_audio() => FootageKind::Audio,
|
||||
_ => FootageKind::Video,
|
||||
};
|
||||
let _ = name;
|
||||
@@ -289,7 +289,7 @@ impl Importer<'_> {
|
||||
frame_rate: rate_in(info.frame_rate()),
|
||||
duration: tick_in(info.duration),
|
||||
has_video: kind != FootageKind::Audio,
|
||||
has_audio: info.audio.is_some() || kind == FootageKind::Audio,
|
||||
has_audio: info.has_audio() || kind == FootageKind::Audio,
|
||||
alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore },
|
||||
codec: v.map(|v| v.codec.clone()).unwrap_or_default(),
|
||||
missing: true,
|
||||
|
||||
@@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }),
|
||||
audio_streams: audio
|
||||
.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
container: String::new(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
@@ -411,7 +415,7 @@ fn ae_project() -> (Project, ItemId) {
|
||||
let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None);
|
||||
ls.out_point = t(3.0);
|
||||
ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]);
|
||||
c.layers = vec![lt, lp, la, ls];
|
||||
c.layers = vec![lt, lp, la, ls].into();
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
(p, cid)
|
||||
}
|
||||
@@ -510,6 +514,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
|
||||
l.transform().unwrap().get("position").unwrap()
|
||||
}
|
||||
|
||||
/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe
|
||||
/// (the export would otherwise animate through it).
|
||||
#[test]
|
||||
fn export_skips_muted_keyframes() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let pos = l.transform_mut().unwrap().get_mut("position").unwrap();
|
||||
pos.keys = vec![
|
||||
Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])),
|
||||
Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])),
|
||||
Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])),
|
||||
];
|
||||
pos.keys[1].muted = true;
|
||||
}
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap();
|
||||
let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe);
|
||||
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
|
||||
let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap();
|
||||
assert_eq!(pos.keys.len(), 2, "the muted key was exported");
|
||||
assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0);
|
||||
assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0);
|
||||
}
|
||||
|
||||
/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the
|
||||
/// active keys matter, and the static value when every key is muted.
|
||||
#[test]
|
||||
fn muted_non_uniform_scale_does_not_force_prerendering() {
|
||||
let mut p = Project::default();
|
||||
let a = footage_item(&mut p, "/media/a.mov");
|
||||
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
|
||||
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
|
||||
{
|
||||
let scale = l.transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))];
|
||||
scale.keys[1].muted = true;
|
||||
}
|
||||
let lid = l.id;
|
||||
c.layers.push(l);
|
||||
let cid = comp_item(&mut p, "Main", c);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None);
|
||||
// With every key muted the static value drives evaluation, so a non-uniform static scale
|
||||
// still takes the flattened path.
|
||||
let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap();
|
||||
scale.keys[0].muted = true;
|
||||
scale.value = Value::Vec3([50.0, 200.0, 100.0]);
|
||||
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_prerender_leaves_text_out() {
|
||||
let (p, cid) = ae_project();
|
||||
|
||||
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user