mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Merge upstream/main into cryptomatte-matte-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4934de892d
63 files changed
+5580
-468
No files matched your search
Generated
+81
-64
@@ -1132,6 +1132,14 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1847,6 +1855,7 @@ dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"rayon",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2421,8 +2430,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2430,16 +2439,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2448,8 +2457,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2458,23 +2467,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-avi"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -2510,16 +2522,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2527,8 +2539,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2537,8 +2549,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -2548,16 +2560,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2566,8 +2578,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2576,8 +2588,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2586,8 +2598,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2603,22 +2615,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
@@ -2632,8 +2646,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2641,8 +2655,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2650,31 +2664,34 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2687,8 +2704,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2697,24 +2714,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/storytold/filmcraft?rev=d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5#d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
||||
+18
-18
@@ -84,29 +84,29 @@ tempfile = "3.27"
|
||||
|
||||
# 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 = "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" }
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# encoders + muxer (crates/export)
|
||||
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" }
|
||||
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# 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" }
|
||||
filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
|
||||
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" }
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-11-05 · **Change:** minor (ProRes alpha final-run conformance; previously: 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
|
||||
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-05: ProRes 4444 and XQ exports write the required run after the final alpha
|
||||
difference. The FilmCraft encoder pin fixes alpha movies rejected by native macOS
|
||||
decoding; an export regression checks the stored bytes for both profiles, alpha modes,
|
||||
and motion-blur settings (#293). Resolve itself still needs confirmation.
|
||||
- 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
|
||||
|
||||
@@ -375,11 +375,25 @@ impl<'f, 'a> EffectParam<'f, 'a> {
|
||||
/// colours, popups and layer controls. A record counts when its `tdsb` is 1 and it has no
|
||||
/// keyframes; a layer control reads the `tdpi` chunk; a button has no value.
|
||||
pub fn stored_value(&self) -> Option<ParamValue> {
|
||||
let p = self.stored?;
|
||||
if self.kind != ParamKind::Layer && p.flags() != Some(1) {
|
||||
return None;
|
||||
}
|
||||
self.recorded_value()
|
||||
}
|
||||
|
||||
/// The value in the value record when the record is written (`tdsb` 1) or in the default
|
||||
/// state (`tdsb` 3): what [`EffectParam::stored_value`] reads, also from a default-state
|
||||
/// record. Lumetri Color writes the values of its hidden parameters so (High Dynamic Range,
|
||||
/// HDR Specular, Look: 1200 `lumetri-01-set-all`, `-02-sec-top`, `-06-sec-light`; After
|
||||
/// Effects reads back the recorded values). `None` with keyframes, without a record or for a
|
||||
/// record in any other state (no sample has one; the importer reports it as damaged).
|
||||
pub fn recorded_value(&self) -> Option<ParamValue> {
|
||||
let p = self.stored?;
|
||||
if self.kind == ParamKind::Layer {
|
||||
return be_u32(p.chunk().child(b"tdpi")?.bytes()?, 0).map(ParamValue::Layer);
|
||||
}
|
||||
if p.flags() != Some(1) || p.is_animated() {
|
||||
if !matches!(p.flags(), Some(1 | 3)) || p.is_animated() {
|
||||
return None;
|
||||
}
|
||||
let v = p.static_value()?;
|
||||
@@ -443,8 +457,9 @@ impl<'f, 'a> EffectParam<'f, 'a> {
|
||||
|
||||
/// True when the parameter is at its default: it has no value record (a record exists exactly
|
||||
/// when the value differs from the `pard` default; setting a value back to the default removes
|
||||
/// the record again), or the record equals the default. `None` when that cannot be decided
|
||||
/// (unknown parameter type).
|
||||
/// the record again), or the record equals the default. A record in the default state (`tdsb`
|
||||
/// 3) counts with its value ([`EffectParam::recorded_value`]). `None` when that cannot be
|
||||
/// decided (unknown parameter type, keyframes).
|
||||
pub fn is_default(&self) -> Option<bool> {
|
||||
match (self.stored, self.kind) {
|
||||
// A Layer Control always has its record, and a popup selection overwrites the default
|
||||
@@ -453,7 +468,7 @@ impl<'f, 'a> EffectParam<'f, 'a> {
|
||||
(None, _) => Some(true),
|
||||
(Some(_), ParamKind::Popup) => Some(false),
|
||||
(Some(_), _) => {
|
||||
let stored = self.stored_value()?;
|
||||
let stored = self.recorded_value()?;
|
||||
// A checkbox's default is in `pard` too (on for Repeat Edge Pixels, 412).
|
||||
Some(self.pard_default().is_some_and(|d| d == stored))
|
||||
}
|
||||
@@ -541,10 +556,11 @@ impl<'f, 'a> EffectParam<'f, 'a> {
|
||||
self.items.as_ref()?.get(index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The effective value: the stored value when there is one, else the `pard` default (absent =
|
||||
/// The effective value: the value in the record when there is one, also in a record in the
|
||||
/// default state (`tdsb` 3, [`EffectParam::recorded_value`]), else the `pard` default (absent =
|
||||
/// default); a checkbox without a record is off.
|
||||
pub fn value(&self) -> Option<ParamValue> {
|
||||
self.stored_value().or_else(|| self.pard_default()).or(match self.kind {
|
||||
self.recorded_value().or_else(|| self.pard_default()).or(match self.kind {
|
||||
ParamKind::Checkbox if self.stored.is_none() => Some(ParamValue::Bool(false)),
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -133,6 +133,8 @@ struct ParamOut<'a> {
|
||||
col: &'a mut Collector,
|
||||
}
|
||||
|
||||
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
|
||||
/// record or the `pard` default, with a loss for what cannot be read or mapped.
|
||||
fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>, cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) {
|
||||
let ParamOut { group, refs, col } = out;
|
||||
if carries_nothing(p) {
|
||||
@@ -269,7 +271,9 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
}
|
||||
return;
|
||||
}
|
||||
let value = match (p.stored(), p.stored_value()) {
|
||||
// A record in the default state (`tdsb` 3) still holds the value After Effects uses (1200,
|
||||
// 1400: every such record reads back as recorded).
|
||||
let value = match (p.stored(), p.recorded_value()) {
|
||||
(Some(_), Some(v)) => Some(v),
|
||||
(Some(_), None) => {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value, default used"));
|
||||
|
||||
@@ -295,3 +295,111 @@ fn unknown_time_display_and_frame_start_codes_are_reported() {
|
||||
assert!(losses(&r.unwrap(), LossCategory::ProjectSettings).is_empty(), "nhed {at}: {bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with the value records named `param` (`<effect>-NNNN`: the instance's and the one in
|
||||
/// the project's copy of the effect, `EfdG`) set to state `tdsb` and holding `value` (the `cdat`
|
||||
/// starts with the big-endian `f64`).
|
||||
fn with_record(param: &str, tdsb: u32, value: f64) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut patched = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if !c.is_list(b"tdgp") {
|
||||
return;
|
||||
}
|
||||
let kids = c.children_mut();
|
||||
let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
|
||||
return;
|
||||
};
|
||||
let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return };
|
||||
for k in record.children_mut() {
|
||||
if k.is(b"tdsb") {
|
||||
set_data(k, 0, &tdsb.to_be_bytes());
|
||||
} else if k.is(b"cdat") {
|
||||
set_data(k, 0, &value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
patched += 1;
|
||||
});
|
||||
assert_eq!(patched, 2, "{param}: the instance and the EfdG copy");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// The values of the imported effect parameters, by property id, in file order.
|
||||
fn effect_values(p: &Project) -> Vec<(String, effectcraft_keyframe::Value)> {
|
||||
let mut out = Vec::new();
|
||||
for item in p.items.values() {
|
||||
if let ItemKind::Comp(c) = &item.kind {
|
||||
for layer in &c.layers {
|
||||
if let Some(fx) = layer.effects() {
|
||||
fx.walk("", &mut |_, prop| out.push((prop.match_id.clone(), prop.value.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A default-state record (`tdsb` 3) reads like a written one, except through `stored_value`.
|
||||
#[test]
|
||||
fn a_default_state_effect_record_holds_the_value() {
|
||||
// After Effects writes the values of some parameters as records in the default state (`tdsb`
|
||||
// 3): Lumetri Color's hidden controls (1200) and Cryptomatte's Selection Mode (1400), each
|
||||
// read back as recorded. The reader reads them like a written record (`tdsb` 1).
|
||||
let bytes = with_record("ADBE Glo2-0011", 3, 0.75);
|
||||
let file = parse(&bytes).unwrap();
|
||||
let project = effectcraft_aep::Project::new(&file).unwrap();
|
||||
let defs = project.effect_definition_index();
|
||||
let mut seen = 0;
|
||||
for item in project.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") {
|
||||
let params = defs.params(fx);
|
||||
let p = params.iter().find(|p| p.match_name == "ADBE Glo2-0011").unwrap();
|
||||
assert_eq!(p.stored().and_then(|s| s.flags()), Some(3));
|
||||
// `stored_value` keeps reading written records only.
|
||||
assert_eq!(p.stored_value(), None);
|
||||
assert_eq!(p.recorded_value(), Some(effectcraft_aep::ParamValue::Number(0.75)));
|
||||
assert_eq!(p.value(), Some(effectcraft_aep::ParamValue::Number(0.75)));
|
||||
assert_eq!(p.is_default(), Some(false));
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, 1);
|
||||
}
|
||||
|
||||
/// Default-state records import with their values and no loss, like written records.
|
||||
#[test]
|
||||
fn default_state_effect_records_import_like_written_ones() {
|
||||
// Before, such a record was reported as an unreadable value and the default used.
|
||||
for (param, value) in [("ADBE Glo2-0011", 0.75), ("ADBE Easy Levels2-0008", 1.0)] {
|
||||
let (written, r1) = run(&with_record(param, 1, value), true);
|
||||
let (default_state, r3) = run(&with_record(param, 3, value), true);
|
||||
let (r1, r3) = (r1.unwrap(), r3.unwrap());
|
||||
assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{param}: {:?}", r3.report.losses);
|
||||
assert_eq!(r3.report.losses, r1.report.losses, "{param}");
|
||||
assert_eq!(effect_values(&default_state), effect_values(&written), "{param}");
|
||||
}
|
||||
// Glow's A & B Midpoint 0.75 is 75 % (the default is 50).
|
||||
let (p, _) = run(&with_record("ADBE Glo2-0011", 3, 0.75), true);
|
||||
let mid: Vec<_> = effect_values(&p).into_iter().filter(|(id, _)| id == "abMidpoint").collect();
|
||||
assert_eq!(mid, vec![("abMidpoint".to_string(), effectcraft_keyframe::Value::Scalar(75.0))]);
|
||||
// Levels' Clip To Output Black (`-0008`) recorded as After Effects' item 1, On: EffectCraft's
|
||||
// On is item 1 of Off, On, Off for 32 bpc Color (the default is item 2).
|
||||
let (p, _) = run(&with_record("ADBE Easy Levels2-0008", 3, 1.0), true);
|
||||
let clip: Vec<_> = effect_values(&p).into_iter().filter(|(id, _)| id == "clipToOutputBlack").collect();
|
||||
assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]);
|
||||
}
|
||||
|
||||
/// A record in a state other than 1 or 3 is not read and is reported.
|
||||
#[test]
|
||||
fn an_effect_record_in_another_state_is_reported_as_damaged() {
|
||||
// Only the written (1) and the default (3) state hold a value; any other state is not read.
|
||||
for tdsb in [0, 2, 5] {
|
||||
let (_, r) = run(&with_record("ADBE Glo2-0011", tdsb, 0.75), true);
|
||||
let r = r.unwrap();
|
||||
let damaged = losses(&r, LossCategory::DamagedData);
|
||||
assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses);
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,9 @@ impl FootageSource for ConfinedFootage {
|
||||
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten()
|
||||
}
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
self.inner.set_aux_footage(paths)
|
||||
}
|
||||
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
self.inside(footage).then(|| self.inner.model(item, footage)).flatten()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,25 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "3D Channel", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
/// The effects that read their layer's auxiliary channels ([`crate::EffectHost::aux`]): every
|
||||
/// 3D Channel effect but 3D Layer Instance.
|
||||
const READS_AUX: [&str; 8] = [
|
||||
"ec.3d.channelextract",
|
||||
"ec.3d.cryptomatte",
|
||||
"ec.3d.depthmatte",
|
||||
"ec.3d.depthoffield",
|
||||
"ec.3d.extractor",
|
||||
"ec.3d.fog3d",
|
||||
"ec.3d.idmatte",
|
||||
"ec.3d.identifier",
|
||||
];
|
||||
|
||||
/// Does effect `id` read its layer's auxiliary channels ([`crate::EffectHost::aux`])? The media
|
||||
/// layer then decodes the footage's picture together with its channels (#474).
|
||||
pub fn reads_aux(id: &str) -> bool {
|
||||
READS_AUX.contains(&id)
|
||||
}
|
||||
|
||||
fn aux(ctx: &EffectCtx) -> Option<Arc<AuxChannels>> {
|
||||
ctx.env.host?.aux()
|
||||
}
|
||||
|
||||
@@ -442,3 +442,27 @@ fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
|
||||
let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, solid().data);
|
||||
}
|
||||
|
||||
/// #474: [`reads_aux`] names exactly the 3D Channel effects that ask the host for channels.
|
||||
#[test]
|
||||
fn reads_aux_names_the_effects_that_read_channels() {
|
||||
struct Counting(AuxHost, std::sync::atomic::AtomicUsize);
|
||||
impl EffectHost for Counting {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn aux(&self) -> Option<Arc<AuxChannels>> {
|
||||
self.1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.0.aux()
|
||||
}
|
||||
}
|
||||
for s in specs() {
|
||||
let host = Counting(scene(), Default::default());
|
||||
run_fx(s.id, &[], img(), 0.0, EffectEnv { host: Some(&host), ..Default::default() });
|
||||
assert_eq!(host.1.into_inner() > 0, reads_aux(s.id), "{}", s.id);
|
||||
}
|
||||
assert!(READS_AUX.iter().all(|id| specs().iter().any(|s| s.id == *id)), "every listed effect exists");
|
||||
}
|
||||
@@ -384,6 +384,18 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
fn corner_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (w, h) = (b.layer_w(ctx), b.layer_h(ctx));
|
||||
// The warped layer is drawn wherever the corners are, so grow the buffer to hold the quad;
|
||||
// an adjustment layer keeps the size of the comp below it.
|
||||
if !ctx.adjustment {
|
||||
let pts = ["ul", "ur", "lr", "ll"].map(|k| {
|
||||
let p = b.to_px(ctx.params.v2(k));
|
||||
[p.0, p.1]
|
||||
});
|
||||
let (lo, hi) = crate::quad_bounds(&pts);
|
||||
if let Some(sides) = crate::grow_sides(lo, hi, b.img.width, b.img.height) {
|
||||
b.pad_sides(sides);
|
||||
}
|
||||
}
|
||||
let q = ["ul", "ur", "lr", "ll"].map(|k| {
|
||||
let p = b.to_px(ctx.params.v2(k));
|
||||
vec2(p.0, p.1)
|
||||
@@ -542,6 +554,74 @@ mod tests {
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_pin_draws_outside_the_layer_rectangle() {
|
||||
// A 20×10 layer pinned to a quad that lies entirely right of and below it.
|
||||
let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let set = [("ul", Value::Vec2([40.0, 30.0])), ("ur", Value::Vec2([60.0, 30.0])), ("lr", Value::Vec2([60.0, 40.0])), ("ll", Value::Vec2([40.0, 40.0]))];
|
||||
let o = run("ec.distort.cornerpin", &set, im);
|
||||
assert!(o.img.width >= 60 && o.img.height >= 40, "{}x{}", o.img.width, o.img.height);
|
||||
let (cx, cy) = (o.offset[0] as i64 + 50, o.offset[1] as i64 + 35);
|
||||
assert!(o.img.get(cx, cy)[0] > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_pin_keeps_the_size_on_an_adjustment_layer() {
|
||||
let s = crate::find("ec.distort.cornerpin").unwrap();
|
||||
let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [20.0, 10.0]))).collect() };
|
||||
params.values.insert("lr".to_string(), Value::Vec2([400.0, 300.0]));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [20.0, 10.0], seed: 3, adjustment: true, env: Default::default() };
|
||||
let o = crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 });
|
||||
assert_eq!((o.img.width, o.img.height, o.offset), (20, 10, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_pin_with_a_far_pin_grows_to_it() {
|
||||
let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let o = run("ec.distort.cornerpin", &[("lr", Value::Vec2([1500.0, 1000.0]))], im);
|
||||
assert!(o.img.width >= 1500 && o.img.height >= 1000, "{}x{}", o.img.width, o.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_pin_with_a_non_finite_pin_returns_a_buffer_without_panicking() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
for corner in ["ul", "ur", "lr", "ll"] {
|
||||
let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let o = run("ec.distort.cornerpin", &[(corner, Value::Vec2([bad, 5.0]))], im);
|
||||
assert!(
|
||||
o.img.width >= 1 && o.img.height >= 1 && o.img.width <= 16384 && o.img.height <= 16384,
|
||||
"{corner} {bad}: {}x{}",
|
||||
o.img.width,
|
||||
o.img.height
|
||||
);
|
||||
assert!(o.offset.iter().all(|v| v.is_finite()), "{corner} {bad}: offset {:?}", o.offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_pin_growth_is_continuous_as_a_pin_moves_far_away() {
|
||||
let mut last: Option<u64> = None;
|
||||
for k in 0..60 {
|
||||
let far = 100.0 * 1.4_f64.powi(k);
|
||||
let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let o = run("ec.distort.cornerpin", &[("lr", Value::Vec2([far, far]))], im);
|
||||
let size = (o.img.width, o.img.height);
|
||||
assert!(size.0 >= 20 && size.1 >= 10 && u64::from(size.0) * u64::from(size.1) <= 1 << 24, "pin {far}: {size:?}");
|
||||
let area = u64::from(size.0) * u64::from(size.1);
|
||||
if let Some(prev) = last {
|
||||
// The shared cut factor may trade width for height as the pin direction changes,
|
||||
// but the buffer as a whole does not shrink (beyond whole-pixel flooring at the budget)
|
||||
// as the pin moves further away.
|
||||
assert!(area * 10_000 >= prev * 9_999, "pin {far}: the buffer shrank from {prev} to {area} pixels ({size:?})");
|
||||
}
|
||||
let opaque = (0..o.img.height as i64).any(|y| (0..o.img.width as i64).any(|x| o.img.get(x, y)[3] > 0.0));
|
||||
assert!(opaque, "pin {far}: the pinned layer vanished");
|
||||
last = Some(area);
|
||||
}
|
||||
}
|
||||
|
||||
fn differs(a: Px4, b: Px4) -> bool {
|
||||
(0..4).any(|i| (a[i] - b[i]).abs() > 1e-4)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
|
||||
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, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, matte_only_px, selection_hashes,
|
||||
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, matte_only_px, reads_aux, selection_hashes,
|
||||
};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
@@ -340,13 +340,19 @@ impl Buf {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
|
||||
/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
|
||||
/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
|
||||
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
|
||||
/// The most pixels a `w` × `h` effect buffer may grow to: 16 Mpx (256 MiB of f32 RGBA) or four
|
||||
/// times its own area, whichever is larger. Shared by every effect that grows its buffer.
|
||||
fn pixel_budget(w: f64, h: f64) -> f64 {
|
||||
const MIN_BUDGET: f64 = 16_777_216.0;
|
||||
MIN_BUDGET.max(4.0 * w * h)
|
||||
}
|
||||
|
||||
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
|
||||
/// [`pixel_budget`]: a small layer far from an effect's centre can't ask for gigabytes (#2261).
|
||||
/// Shared with the GPU path.
|
||||
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let budget = MIN_BUDGET.max(4.0 * w * h);
|
||||
let budget = pixel_budget(w, h);
|
||||
if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
|
||||
return pad;
|
||||
}
|
||||
@@ -355,6 +361,51 @@ pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
|
||||
(p.floor().max(0.0) as u32).min(pad)
|
||||
}
|
||||
|
||||
/// The most pixels on a side that [`grow_sides`] grows a buffer to.
|
||||
const MAX_GROWN_SIDE: f64 = 16384.0;
|
||||
|
||||
/// The transparent pixels to add (left, top, right, bottom; for [`Buf::pad_sides`]) so a
|
||||
/// `width` × `height` buffer also spans the pixel box `lo`..`hi`, in the buffer's own coordinates.
|
||||
/// When the full growth would pass [`MAX_GROWN_SIDE`] or the [`pixel_budget`], every side is cut
|
||||
/// by the same factor instead of giving up, so the picture shrinks smoothly rather than popping.
|
||||
/// `None` when the buffer already spans the box, or the box is not finite.
|
||||
pub fn grow_sides(lo: [f64; 2], hi: [f64; 2], width: u32, height: u32) -> Option<[u32; 4]> {
|
||||
if !lo.iter().chain(hi.iter()).all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (f64::from(width), f64::from(height));
|
||||
let want = |v: f64| v.clamp(0.0, MAX_GROWN_SIDE);
|
||||
let [l, t] = [want(-lo[0].floor()), want(-lo[1].floor())];
|
||||
let [r, b] = [want(hi[0].ceil() - w), want(hi[1].ceil() - h)];
|
||||
let (sx, sy) = (l + r, t + b);
|
||||
if sx == 0.0 && sy == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut f = 1.0_f64;
|
||||
if sx > 0.0 {
|
||||
f = f.min((MAX_GROWN_SIDE - w).max(0.0) / sx);
|
||||
}
|
||||
if sy > 0.0 {
|
||||
f = f.min((MAX_GROWN_SIDE - h).max(0.0) / sy);
|
||||
}
|
||||
let budget = pixel_budget(w, h);
|
||||
if (w + sx * f) * (h + sy * f) > budget {
|
||||
// (w + sx·f)(h + sy·f) = budget, solved for f.
|
||||
let (a, bq, c) = (sx * sy, w * sy + h * sx, w * h - budget);
|
||||
let q = if a > 0.0 { (-bq + (bq * bq - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a) } else { -c / bq };
|
||||
f = f.min(q);
|
||||
}
|
||||
let f = if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let sides = [l, t, r, b].map(|v| (v * f).floor() as u32);
|
||||
sides.iter().any(|&v| v != 0).then_some(sides)
|
||||
}
|
||||
|
||||
/// The pixel box (min, max) enclosing the four points of a quad.
|
||||
pub fn quad_bounds(q: &[[f64; 2]; 4]) -> ([f64; 2], [f64; 2]) {
|
||||
let f = |i: usize, init: f64, g: fn(f64, f64) -> f64| q.iter().fold(init, |a, p| g(a, p[i]));
|
||||
([f(0, f64::INFINITY, f64::min), f(1, f64::INFINITY, f64::min)], [f(0, f64::NEG_INFINITY, f64::max), f(1, f64::NEG_INFINITY, f64::max)])
|
||||
}
|
||||
|
||||
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
|
||||
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
|
||||
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
|
||||
@@ -1418,6 +1469,30 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1938: Corner Pin's growth spans the pinned quad, and past the side cap or the pixel
|
||||
/// budget it is cut by one factor instead of being dropped.
|
||||
#[test]
|
||||
fn grow_sides_spans_the_box_and_clamps_instead_of_giving_up() {
|
||||
let area = |w: u32, h: u32, s: [u32; 4]| (u64::from(w) + u64::from(s[0]) + u64::from(s[2])) * (u64::from(h) + u64::from(s[1]) + u64::from(s[3]));
|
||||
assert_eq!(grow_sides([-3.5, 2.0], [30.0, 40.2], 20, 10), Some([4, 0, 10, 31]));
|
||||
assert_eq!(grow_sides([1.0, 1.0], [19.0, 9.0], 20, 10), None, "already inside");
|
||||
assert_eq!(grow_sides([f64::NAN, 0.0], [30.0, 40.0], 20, 10), None);
|
||||
assert_eq!(grow_sides([0.0, 0.0], [f64::INFINITY, 40.0], 20, 10), None);
|
||||
// A pin near (16000, 16000) on a small layer: cut to the budget, still growing, in the same ratio.
|
||||
let s = grow_sides([0.0, 0.0], [16000.0, 16000.0], 20, 10).unwrap();
|
||||
assert!(s[2] > 1000 && s[3] > 1000 && area(20, 10, s) <= 1 << 24, "{s:?}");
|
||||
// An absurdly far pin is still bounded and still grows.
|
||||
let s = grow_sides([-1e12, -1e12], [1e12, 1e12], 1920, 1080).unwrap();
|
||||
assert!(s.iter().all(|&v| v > 0) && area(1920, 1080, s) <= 1 << 24, "{s:?}");
|
||||
assert!(1920 + u64::from(s[0]) + u64::from(s[2]) <= 16384);
|
||||
// A layer already past the side cap does not grow.
|
||||
assert_eq!(grow_sides([0.0, 0.0], [20000.0, 100.0], 17000, 50), None);
|
||||
// A degenerate quad (all four points equal) grows to its point only.
|
||||
let q = [[50.0, 30.0]; 4];
|
||||
let (lo, hi) = quad_bounds(&q);
|
||||
assert_eq!(grow_sides(lo, hi, 20, 10), Some([0, 0, 30, 20]));
|
||||
}
|
||||
|
||||
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
|
||||
/// big layers may still grow to four times their area, and small pads are untouched.
|
||||
#[test]
|
||||
|
||||
@@ -55,29 +55,42 @@ fn overbrights(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Soft-knee, hue-preserving compression of the brightest channel into [0, 1).
|
||||
fn hdr_compress_value(m: f32, knee: f32) -> f32 {
|
||||
if m <= knee {
|
||||
return m;
|
||||
}
|
||||
let w = 1.0 - knee;
|
||||
knee + w * (m - knee) / ((m - knee) + w)
|
||||
/// Luma weights HDR Highlight Compression divides by (measured against After Effects 2026).
|
||||
const HDR_LUMA: [f32; 3] = [0.30, 0.59, 0.11];
|
||||
/// Exponent of the domain the Amount blend happens in (out^0.4 is mixed linearly).
|
||||
const HDR_BLEND_GAMMA: f32 = 0.4;
|
||||
|
||||
/// `v^e` with the sign of `v` kept (negative input stays negative, as on the GPU).
|
||||
#[inline]
|
||||
fn hdr_spow(v: f32, e: f32) -> f32 {
|
||||
v.signum() * v.abs().powf(e)
|
||||
}
|
||||
|
||||
/// One straight-colour pixel through HDR Highlight Compression at blend `t` (Amount / 100, > 0).
|
||||
///
|
||||
/// Measured behaviour (16-bit renders of a 0–4 HDR ramp and coloured patches at Amount 100,
|
||||
/// 90, 84, 75, 50 and 25): every channel is divided by `1 + Y` (`Y` = 0.30 R + 0.59 G + 0.11 B, so a grey
|
||||
/// value `x` lands on `x / (1 + x)`), clipped at 1, then mixed with the input as `x^0.4` values.
|
||||
fn hdr_compress_pixel(c: [f32; 3], t: f32) -> [f32; 3] {
|
||||
let y = (HDR_LUMA[0] * c[0] + HDR_LUMA[1] * c[1] + HDR_LUMA[2] * c[2]).max(0.0);
|
||||
let s = 1.0 / (1.0 + y);
|
||||
let g = HDR_BLEND_GAMMA;
|
||||
c.map(|x| {
|
||||
let comp = (x * s).min(1.0);
|
||||
let mixed = (1.0 - t) * hdr_spow(x, g) + t * hdr_spow(comp, g);
|
||||
let o = hdr_spow(mixed, 1.0 / g);
|
||||
if o.is_finite() { o } else { x }
|
||||
})
|
||||
}
|
||||
|
||||
/// HDR Highlight Compression: every straight-colour pixel through [`hdr_compress_pixel`] at
|
||||
/// Amount / 100; Amount 0 or NaN returns the layer unchanged.
|
||||
fn hdr_compression(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let t = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
if t <= 0.0 {
|
||||
if t <= 0.0 || !t.is_finite() {
|
||||
return b;
|
||||
}
|
||||
let knee = 1.0 - 0.5 * t;
|
||||
b.img.map_straight(|c| {
|
||||
let m = c[0].max(c[1]).max(c[2]);
|
||||
if m <= knee {
|
||||
return c;
|
||||
}
|
||||
let k = hdr_compress_value(m, knee) / m;
|
||||
c.map(|v| v * k)
|
||||
});
|
||||
b.img.map_straight(|c| hdr_compress_pixel(c, t));
|
||||
b
|
||||
}
|
||||
|
||||
@@ -575,10 +588,68 @@ mod tests {
|
||||
let mut img = Image::new(2, 1);
|
||||
img.set(0, 0, [0.2, 0.3, 0.4, 1.0]);
|
||||
img.set(1, 0, [4.0, 2.0, 1.0, 1.0]);
|
||||
let out = run("ec.utility.hdrcompression", &[("amount", num(0.0))], img.clone());
|
||||
assert_eq!(out.img.data, img.data, "Amount 0 leaves the image alone");
|
||||
let out = run("ec.utility.hdrcompression", &[], img);
|
||||
assert_eq!(out.img.data[0], [0.2, 0.3, 0.4, 1.0]);
|
||||
let o = out.img.data[1];
|
||||
assert!(o[0] <= 1.0 && o[0] > 0.5 && (o[0] / o[1] - 2.0).abs() < 1e-4, "{o:?}");
|
||||
for o in &out.img.data {
|
||||
assert!(o.iter().all(|v| v.is_finite() && *v <= 1.0), "{o:?}");
|
||||
}
|
||||
// Negative and huge input stays finite.
|
||||
let mut img = Image::new(2, 1);
|
||||
img.set(0, 0, [-0.5, 0.2, 3.0, 1.0]);
|
||||
img.set(1, 0, [1e30, -1e30, 0.0, 1.0]);
|
||||
let out = run("ec.utility.hdrcompression", &[("amount", num(50.0))], img);
|
||||
assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data);
|
||||
}
|
||||
|
||||
/// A linear value up to 1 as a code of After Effects' 16-bit PNG renders: the sRGB curve × 6553.5.
|
||||
fn png_code(x: f32) -> f32 {
|
||||
(if x <= 0.003_130_8 { 12.92 * x } else { 1.055 * x.powf(1.0 / 2.4) - 0.055 }) * 6553.5
|
||||
}
|
||||
|
||||
/// Our own measurements of After Effects 2026 (32 bpc, no working space; 16-bit PNG renders
|
||||
/// decoded back to linear): input pixel, then the output at Amount 100, 90, 84, 75, 50 and 25.
|
||||
#[test]
|
||||
fn hdr_compression_matches_measured_reference() {
|
||||
const AMOUNTS: [f64; 6] = [100.0, 90.0, 84.0, 75.0, 50.0, 25.0];
|
||||
#[rustfmt::skip]
|
||||
let table: [([f32; 3], [[f32; 3]; 6]); 9] = [
|
||||
([0.18005371; 3], [[0.1524; 3], [0.1550; 3], [0.1566; 3], [0.1590; 3], [0.1660; 3], [0.1729; 3]]),
|
||||
([0.49365234; 3], [[0.3302; 3], [0.3450; 3], [0.3540; 3], [0.3674; 3], [0.4073; 3], [0.4492; 3]]),
|
||||
([1.0058594; 3], [[0.5014; 3], [0.5427; 3], [0.5683; 3], [0.6085; 3], [0.7280; 3], [0.8602; 3]]),
|
||||
([2.0058594; 3], [[0.6673; 3], [0.7637; 3], [0.8256; 3], [0.9232; 3], [1.2292; 3], [1.5892; 3]]),
|
||||
([2.9941406; 3], [[0.7496; 3], [0.8962; 3], [0.9922; 3], [1.1465; 3], [1.6475; 3], [2.2604; 3]]),
|
||||
([3.9941406; 3], [[0.7998; 3], [0.9929; 3], [1.1213; 3], [1.3303; 3], [2.0312; 3], [2.9141; 3]]),
|
||||
([0.7998047, 0.099975586, 0.099975586], [
|
||||
[0.6101, 0.0762, 0.0762], [0.6279, 0.0784, 0.0784], [0.6386, 0.0798, 0.0798],
|
||||
[0.6547, 0.0817, 0.0817], [0.7012, 0.0877, 0.0877], [0.7496, 0.0937, 0.0937],
|
||||
]),
|
||||
([0.099975586, 0.7001953, 0.19995117], [
|
||||
[0.0681, 0.4776, 0.1363], [0.0711, 0.4976, 0.1420], [0.0727, 0.5097, 0.1456],
|
||||
[0.0754, 0.5287, 0.1508], [0.0832, 0.5829, 0.1662], [0.0912, 0.6397, 0.1825],
|
||||
]),
|
||||
([0.30004883, 0.5, 2.5], [
|
||||
[0.1807, 0.3012, 0.9998], [0.1911, 0.3184, 1.1147], [0.1972, 0.3288, 1.1860],
|
||||
[0.2071, 0.3453, 1.2995], [0.2359, 0.3931, 1.6484], [0.2669, 0.4444, 2.0482],
|
||||
]),
|
||||
];
|
||||
let mut img = Image::new(table.len() as u32, 1);
|
||||
for (x, (c, _)) in table.iter().enumerate() {
|
||||
img.set(x as u32, 0, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
for (i, amount) in AMOUNTS.into_iter().enumerate() {
|
||||
let out = run("ec.utility.hdrcompression", &[("amount", num(amount))], img.clone());
|
||||
for (x, (input, want)) in table.iter().enumerate() {
|
||||
let got = out.img.data[x];
|
||||
for c in 0..3 {
|
||||
let w = want[i][c];
|
||||
// Up to 1: within 20 codes of the 16-bit PNG (the sRGB curve × 6553.5) the
|
||||
// renders were read from. Above 1 the PNG's lookup table is not known: 0.1 %.
|
||||
let ok = if w <= 1.0 { (png_code(got[c]) - png_code(w)).abs() <= 20.0 } else { (got[c] - w).abs() <= w * 1e-3 };
|
||||
assert!(ok, "amount {amount}, input {input:?}, channel {c}: {} vs AE {w}", got[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,7 +13,9 @@ use std::sync::Arc;
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use effectcraft_raster::inpaint::SpillHandle;
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, Frames, HoleMask, dilate};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -170,11 +172,76 @@ pub struct FillPlan {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled
|
||||
/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to
|
||||
/// report up front, instead of running out of memory partway through.
|
||||
/// Transient full frames one parallel stage holds per worker: the membrane reads a frame and
|
||||
/// returns a copy, the flow stage holds the neighbouring pair plus the luma pyramids, and
|
||||
/// Lighting Correction adds the mismatch ring (`diff`, its mask) on top.
|
||||
///
|
||||
/// Measured with `measure_peak_rss_of_a_fill` (90 frames, 640×360, Surface): peak RSS grows by
|
||||
/// ~7 frames a rayon worker at the top of its run-to-run spread, and Lighting Correction stays
|
||||
/// below the flow stage's peak, so 8 (9 with lighting) is a conservative constant (#2570).
|
||||
fn per_worker_frames(lighting: bool) -> u64 {
|
||||
if lighting { 9 } else { 8 }
|
||||
}
|
||||
|
||||
/// The sequential synthesis stage: the stored frame, the PatchMatch pyramid and its
|
||||
/// coarse-to-fine copies, the warped previous frame, and the sink's prepared copy.
|
||||
const SYNTH_FRAMES: u64 = 6;
|
||||
|
||||
/// Workers a range of `frames` can keep busy (rayon never runs more tasks than frames). The
|
||||
/// scratch estimate must follow the pool that actually runs the parallel stages: a synchronous
|
||||
/// fill stays in the caller's pool, while an asynchronous one runs on `spawn_task`'s own OS
|
||||
/// thread and so uses the global pool, whatever pool the command was called from (#2570).
|
||||
fn fill_workers(frames: usize, wait: bool) -> u64 {
|
||||
let pool = if wait { rayon::current_num_threads() as u64 } else { global_pool_threads() };
|
||||
pool.min(frames.max(1) as u64)
|
||||
}
|
||||
|
||||
/// The global Rayon pool's thread count, read from a thread outside every pool:
|
||||
/// `rayon::current_num_threads()` answers for the caller's pool, not the one the background
|
||||
/// task uses. Falls back to the current pool when the probe thread cannot start.
|
||||
fn global_pool_threads() -> u64 {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
match std::thread::Builder::new().name("fill-pool-probe".into()).spawn(move || {
|
||||
let _ = tx.send(rayon::current_num_threads());
|
||||
}) {
|
||||
Ok(handle) => {
|
||||
let n = rx.recv().map(|n| n as u64).unwrap_or_else(|_| rayon::current_num_threads() as u64);
|
||||
let _ = handle.join();
|
||||
n
|
||||
}
|
||||
Err(_) => rayon::current_num_threads() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Native builds spill the input, the membrane-filled neighbours and the propagated copies, and
|
||||
/// pack the holes to one bit per pixel, so only the two packed masks and the two flow fields
|
||||
/// (~1/2 byte a pixel a frame) plus the workers' scratch stay resident (#2570). wasm has no
|
||||
/// spill and keeps the input and the working copies in RAM (~32 bytes a pixel a frame). When
|
||||
/// even that peak is more than `available`, report it up front instead of running out of memory
|
||||
/// partway through (#1789, #2570 review).
|
||||
#[cfg(test)]
|
||||
fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<String> {
|
||||
let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33);
|
||||
memory_shortfall_with(frames, w, h, available, fill_workers(frames, true), false, 0)
|
||||
}
|
||||
|
||||
/// `workers` and `lighting` are parameters so the budget is testable: the scratch term used to
|
||||
/// be a fixed 8 frames, which under-counted a wide machine by several times (#2570 review).
|
||||
/// `resident` adds storage that would otherwise be spill: when the spill volume is RAM-backed,
|
||||
/// its files use the same memory as the working set (#2570 review).
|
||||
fn memory_shortfall_with(frames: usize, w: u32, h: u32, available: u64, workers: u64, lighting: bool, resident: u64) -> Option<String> {
|
||||
let px = u64::from(w).saturating_mul(u64::from(h));
|
||||
let per_frame = (frames as u64).saturating_mul(px);
|
||||
// `holes` and the leftover propagation masks, packed to a bit a pixel each.
|
||||
let masks = per_frame / 4;
|
||||
// The forward and backward flow fields, ~0.125 byte a pixel each.
|
||||
let flows = per_frame / 4;
|
||||
// wasm keeps the input and one working copy in RAM at every stage; a RAM-backed spill
|
||||
// volume counts there too.
|
||||
let resident = resident.saturating_add(if cfg!(target_arch = "wasm32") { per_frame.saturating_mul(32) } else { 0 });
|
||||
// The synthesis stage is sequential; every parallel stage holds a few frames a worker.
|
||||
let scratch_frames = SYNTH_FRAMES.saturating_add(per_worker_frames(lighting).saturating_mul(workers));
|
||||
let scratch = px.saturating_mul(16).saturating_mul(scratch_frames);
|
||||
let need = masks.saturating_add(flows).saturating_add(resident).saturating_add(scratch);
|
||||
(need > available).then(|| {
|
||||
let gb = |b: u64| b as f64 / 1e9;
|
||||
format!(
|
||||
@@ -185,6 +252,40 @@ fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<Str
|
||||
})
|
||||
}
|
||||
|
||||
/// Scratch floor for a native fill: the input, membrane-filled and propagated frames are each
|
||||
/// spilled as f32 RGBA (16 bytes a pixel a frame), but the membrane spill is closed before the
|
||||
/// propagated one is made, so at most two copies exist at once (2 × 16 bytes a pixel a frame),
|
||||
/// plus a tenth for the PNG output.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scratch_need(frames: usize, w: u32, h: u32) -> u64 {
|
||||
let bytes = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(32);
|
||||
bytes.saturating_add(bytes / 10)
|
||||
}
|
||||
|
||||
/// Refuse up front when the output volume cannot hold the fill's scratch. The folder may not
|
||||
/// exist yet (the spill creates it), so query the nearest existing ancestor. `None` when the
|
||||
/// volume cannot be read; the write itself still reports a real error then (#2570 review).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scratch_shortfall(frames: usize, w: u32, h: u32, dir: &std::path::Path) -> Option<String> {
|
||||
let need = scratch_need(frames, w, h);
|
||||
// Resolve a relative output folder against the process directory; `parent()` of a bare
|
||||
// name is empty, which would otherwise skip the check.
|
||||
let base = if dir.is_absolute() { dir.to_path_buf() } else { std::env::current_dir().ok()?.join(dir) };
|
||||
let mut probe = base.as_path();
|
||||
while !probe.exists() {
|
||||
probe = probe.parent()?;
|
||||
}
|
||||
let (_, free) = crate::sysinfo::disk_space(probe)?;
|
||||
(need > free).then(|| {
|
||||
let gb = |b: u64| b as f64 / 1e9;
|
||||
format!(
|
||||
"Content-Aware Fill of {frames} frames at {w}×{h} needs up to {:.1} GB of scratch space beside the output, and {:.1} GB is free there: fill a shorter range (set the work area and choose Work Area) or choose an output folder on a larger disk",
|
||||
gb(need),
|
||||
gb(free)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The status line shown while the frames are filled. Edge Blend calls only the membrane, which
|
||||
/// the device backend leaves to the CPU reference, so it is not a device fill even when a `fill`
|
||||
/// backend is present; Object and Surface run the flow and PatchMatch on the device.
|
||||
@@ -192,6 +293,43 @@ fn filling_status(on_device: bool, method: FillMethod) -> &'static str {
|
||||
if on_device && method != FillMethod::EdgeBlend { "Filling (GPU)" } else { "Filling" }
|
||||
}
|
||||
|
||||
/// The PNGs a fill has written so far. Unless the fill finishes ([`OutputFiles::keep`]), dropping
|
||||
/// it deletes the ones it created, and the output folder too when the fill made it and nothing
|
||||
/// else is in it, so a failed or stopped fill leaves no partial sequence behind (#2570 review). A
|
||||
/// file that was already there (an `outputDir` holding an earlier sequence) is never deleted.
|
||||
struct OutputFiles {
|
||||
dir: std::path::PathBuf,
|
||||
/// The folder was missing before the fill (the spill creates it).
|
||||
made_dir: bool,
|
||||
paths: Vec<String>,
|
||||
/// Paths among `paths` that existed before the fill wrote them: not the fill's to delete.
|
||||
spare: Vec<String>,
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
impl OutputFiles {
|
||||
/// The fill finished: keep its files.
|
||||
fn keep(mut self) -> Vec<String> {
|
||||
self.kept = true;
|
||||
std::mem::take(&mut self.paths)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OutputFiles {
|
||||
fn drop(&mut self) {
|
||||
if self.kept {
|
||||
return;
|
||||
}
|
||||
for path in self.paths.iter().filter(|p| !self.spare.contains(p)) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
// `remove_dir` leaves a folder that still holds anything else.
|
||||
if self.made_dir {
|
||||
let _ = std::fs::remove_dir(&self.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
///
|
||||
@@ -224,17 +362,34 @@ pub(crate) async fn run_plan(
|
||||
let ctx0 = EvalCtx { expr, ..EvalCtx::new(project, cid, comp, Tick::ZERO) };
|
||||
let times = &plan.times;
|
||||
let n = times.len() as u64;
|
||||
let mut frames = Vec::with_capacity(times.len());
|
||||
let mut holes = Vec::with_capacity(times.len());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut frames = Vec::with_capacity(times.len());
|
||||
// Declared before the spill, so on an early return the spill is closed first and the folder
|
||||
// it was made in can be removed.
|
||||
let dir = std::path::Path::new(&plan.dir);
|
||||
let mut written = OutputFiles { dir: dir.to_path_buf(), made_dir: !dir.exists(), paths: Vec::with_capacity(times.len()), spare: Vec::new(), kept: false };
|
||||
// Spill 32-bit floats whatever the project depth: the old in-memory path kept f32 all the
|
||||
// way through, so quantising here would change the fill (#2570, "no quality loss"). Scratch
|
||||
// lives beside the output when one is set; the default temp folder may be RAM-backed, which
|
||||
// the memory preflight accounts for when it chooses the spill folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let spill = SpillHandle::create_in(Some(dir), w as usize, h as usize, times.len(), 32)
|
||||
.map_err(|e| format!("spill: cannot make the scratch file in {}: {e}", plan.dir))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let spill_probe = spill.clone();
|
||||
for (k, t) in times.iter().enumerate() {
|
||||
let (img, hole) = crate::offload::in_passes(accel, |a| masked_frame(&r.with_accel(a), &ctx0.at(*t), layer, w, h)).await;
|
||||
holes.push(dilate(&hole, w as usize, h as usize, plan.expand));
|
||||
holes.push(HoleMask::from_vec(&dilate(&hole, w as usize, h as usize, plan.expand)));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
frames.push(img);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
spill.write_frame(k, &img)?;
|
||||
if !progress(k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
}
|
||||
if !holes.iter().any(|h| h.iter().any(|b| *b)) {
|
||||
if !holes.iter().any(|h| h.any()) {
|
||||
return Err("the layer has no transparent area to fill: draw a mask (Subtract) around what to remove".into());
|
||||
}
|
||||
let mut references = vec![];
|
||||
@@ -249,31 +404,94 @@ pub(crate) async fn run_plan(
|
||||
}
|
||||
let opts = FillOpts { method: FillMethod::from_name(&plan.method).unwrap_or_default(), lighting: plan.lighting, ..Default::default() };
|
||||
message(filling_status(fill.is_some(), opts.method));
|
||||
let input = FillInput { frames, holes, references };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let input_frames = Frames::Memory(frames);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let input_frames = Frames::Spilled(spill);
|
||||
let input = FillInput { frames: input_frames, holes, references };
|
||||
let cpu = effectcraft_raster::inpaint::CpuFill;
|
||||
let backend: &dyn effectcraft_raster::inpaint::FillBackend = fill.unwrap_or(&cpu);
|
||||
let out = effectcraft_raster::inpaint::fill_sequence_with(input, &opts, backend, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3))
|
||||
.ok_or(crate::render_queue::CANCELLED)?;
|
||||
message("Writing the fill layer");
|
||||
let dir = &plan.dir;
|
||||
let mut paths = vec![];
|
||||
for (k, img) in out.iter().enumerate() {
|
||||
let path = std::path::Path::new(dir).join(format!("fill_{k:05}.png")).to_string_lossy().to_string();
|
||||
services.store_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?;
|
||||
paths.push(path);
|
||||
if !progress(2 * n + k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
let progress = std::rc::Rc::new(std::cell::RefCell::new(progress));
|
||||
// The fill and the sink both advance the bar: a streamed frame reports ahead of the fill's own
|
||||
// progress, so both report a shared high-water mark and the bar never moves backwards.
|
||||
let high = std::cell::Cell::new(0u64);
|
||||
let report = |done: u64| -> bool {
|
||||
let done = done.max(high.get());
|
||||
high.set(done);
|
||||
(progress.borrow_mut())(done, n * 3)
|
||||
};
|
||||
let paths: Vec<String> = (0..n as usize).map(|k| dir.join(format!("fill_{k:05}.png")).to_string_lossy().to_string()).collect();
|
||||
let mut sink_error: Option<String> = None;
|
||||
let mut sink = |idx: usize, img: &Image| -> std::result::Result<(), String> {
|
||||
let r = (|| -> std::result::Result<(), String> {
|
||||
// A failed spilled read returns a black fallback frame; never publish it (#2570
|
||||
// review).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if spill_probe.has_error() {
|
||||
return Err(spill_probe.take_error().unwrap_or_else(|| "spill: read failed".to_string()));
|
||||
}
|
||||
let existed = services.exists(&paths[idx]);
|
||||
services.store_file(&paths[idx], &png(img)?).map_err(|e| format!("{}: {e}", paths[idx]))?;
|
||||
written.paths.push(paths[idx].clone());
|
||||
if existed {
|
||||
written.spare.push(paths[idx].clone());
|
||||
}
|
||||
if !report(2 * n + idx as u64 + 1) {
|
||||
return Err(crate::render_queue::CANCELLED.to_string());
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = &r
|
||||
&& sink_error.is_none()
|
||||
{
|
||||
sink_error = Some(e.clone());
|
||||
}
|
||||
r
|
||||
};
|
||||
let produced =
|
||||
effectcraft_raster::inpaint::fill_sequence_with(input, &opts, backend, &mut |d, t| report(n + (d as u64 * n / t.max(1) as u64)), Some(&mut sink));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(e) = spill_probe.take_error() {
|
||||
return Err(e);
|
||||
}
|
||||
if produced.is_none() {
|
||||
if let Some(e) = sink_error.take() {
|
||||
return Err(e);
|
||||
}
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let produced = produced.unwrap_or_default();
|
||||
// Non-streaming fallback (tests): write any retained images.
|
||||
if !produced.is_empty() {
|
||||
for (k, img) in produced.iter().enumerate() {
|
||||
let path = paths[k].clone();
|
||||
services.store_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?;
|
||||
if written.paths.len() <= k {
|
||||
written.paths.push(path);
|
||||
}
|
||||
if !report(2 * n + k as u64 + 1) {
|
||||
return Err(crate::render_queue::CANCELLED.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(paths)
|
||||
// Every frame must have a file: the fill layer is built on the first one, and a short
|
||||
// sequence would play the wrong frames.
|
||||
if written.paths.len() != times.len() {
|
||||
return Err(format!("Content-Aware Fill wrote {} of {} frames", written.paths.len(), times.len()));
|
||||
}
|
||||
Ok(written.keep())
|
||||
}
|
||||
|
||||
/// Add the fill layer of a finished plan (one undo step).
|
||||
/// The plan comes back from a job worker, so an empty range is an error, not a crash.
|
||||
pub(crate) fn apply_plan(s: &mut Session, plan: &FillPlan, paths: Vec<String>) -> Result<Value> {
|
||||
let importer = s.importer.clone();
|
||||
add_fill_layer(s, plan.comp, plan.layer, &plan.name, paths, (plan.width, plan.height), plan.times[0], plan.times.len(), importer)
|
||||
let start = plan.times.first().copied().ok_or_else(|| bad("contentFill.generate", "the fill has no frames"))?;
|
||||
add_fill_layer(s, plan.comp, plan.layer, &plan.name, paths, (plan.width, plan.height), start, plan.times.len(), importer)
|
||||
}
|
||||
|
||||
/// `contentFill.generate`: validate the layer and range, refuse a fill that cannot fit in
|
||||
/// memory or scratch space, then run it (in the background unless `wait` is set).
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "contentFill.generate";
|
||||
let (cid, lid) = layer_p(s, p, c)?;
|
||||
@@ -307,17 +525,35 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
|
||||
let times: Vec<Tick> = (fa..=fb).map(|f| fr.tick_of(f)).collect();
|
||||
if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) {
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
let opts = FillOpts {
|
||||
method: FillMethod::from_name(&st.method).unwrap_or_default(),
|
||||
lighting: lighting(&st.lighting_correction).unwrap_or(0.0),
|
||||
..Default::default()
|
||||
};
|
||||
let dir = str_p(p, "outputDir").map(str::to_string).unwrap_or_else(|| default_dir(s, &layer.name));
|
||||
// The spill lives beside the output. On a RAM-backed volume (`tmpfs`/`ramfs`, where the
|
||||
// default temp dir may live) its files use the same memory as the working set, so the
|
||||
// memory preflight counts them; disk-backed volumes get the space check below instead.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let ram_spill = if crate::sysinfo::is_memory_backed(std::path::Path::new(&dir)) { scratch_need(times.len(), w, h) } else { 0 };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ram_spill = 0;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
// `spawn_task` runs a fill inline on wasm, and `runs_inline` selects the pool it will use.
|
||||
let runs_inline = wait || cfg!(target_arch = "wasm32");
|
||||
if let Some(e) = crate::sysinfo::memory()
|
||||
.and_then(|m| memory_shortfall_with(times.len(), w, h, m.available, fill_workers(times.len(), runs_inline), opts.lighting > 0.0, ram_spill))
|
||||
{
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
let expand = st.alpha_expansion.round().max(0.0) as usize;
|
||||
let reference = st.reference_layer.filter(|r| comp.layer(*r).is_some()).map(|r| (r, Tick::from_seconds_f64(st.reference_time.unwrap_or(0.0))));
|
||||
let dir = str_p(p, "outputDir").map(str::to_string).unwrap_or_else(|| default_dir(s, &layer.name));
|
||||
// Native fills keep up to two f32 copies of the range in scratch beside the output; a volume
|
||||
// that cannot hold them is refused before any frame is rendered (#2570 review).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(e) = scratch_shortfall(times.len(), w, h, std::path::Path::new(&dir)) {
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
let plan = FillPlan {
|
||||
comp: cid,
|
||||
layer: lid,
|
||||
@@ -331,7 +567,6 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reference,
|
||||
dir,
|
||||
};
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
// The browser: a job worker (its own GPU), the page keeps running.
|
||||
if !wait && s.offloads() {
|
||||
s.offload_analysis(crate::offload::WorkerJob::ContentFill { plan: Box::new(plan) }).map_err(EngineError::Other)?;
|
||||
@@ -377,12 +612,13 @@ fn add_fill_layer(
|
||||
count: usize,
|
||||
importer: Option<Arc<dyn crate::Importer>>,
|
||||
) -> Result<Value> {
|
||||
let first = paths.first().cloned().ok_or_else(|| bad("contentFill.generate", "the fill wrote no frames"))?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let src = comp.layer(lid).ok_or_else(|| bad("contentFill.generate", "the source layer is gone"))?.clone();
|
||||
let fr = comp.frame_rate;
|
||||
let dur = fr.tick_of(count as i64);
|
||||
let mut footage = importer.and_then(|i| i.probe(&paths[0]).ok()).filter(|f| f.has_video).unwrap_or_else(|| Footage {
|
||||
path: paths[0].clone(),
|
||||
let mut footage = importer.and_then(|i| i.probe(&first).ok()).filter(|f| f.has_video).unwrap_or_else(|| Footage {
|
||||
path: first.clone(),
|
||||
kind: FootageKind::Sequence,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
@@ -468,7 +704,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filling_status, memory_shortfall};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::scratch_need;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::scratch_shortfall;
|
||||
use super::{filling_status, memory_shortfall, memory_shortfall_with};
|
||||
|
||||
/// Edge Blend fills with the membrane, which the device backend leaves to the CPU reference:
|
||||
/// it must not claim a GPU fill just because a backend is available.
|
||||
@@ -481,13 +721,88 @@ mod tests {
|
||||
assert_eq!(filling_status(false, FillMethod::Object), "Filling");
|
||||
}
|
||||
|
||||
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
|
||||
/// #1789 / #2570: a range that cannot fit is refused up front instead of OOM-ing. The
|
||||
/// scratch term scales with the workers and Lighting Correction, not a fixed frame count.
|
||||
#[test]
|
||||
fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() {
|
||||
let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused");
|
||||
let e = memory_shortfall_with(300, 1920, 1080, 1_000_000, 8, false, 0).expect("refused");
|
||||
assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}");
|
||||
assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits");
|
||||
// Four workers, no lighting: the packed masks and flows plus ~38 scratch frames fit.
|
||||
assert_eq!(memory_shortfall_with(30, 1920, 1080, 4_000_000_000, 4, false, 0), None);
|
||||
assert_eq!(memory_shortfall_with(300, 1920, 1080, 2_000_000_000, 4, false, 0), None);
|
||||
// A wide machine with Lighting Correction holds nine frames a worker: 300 frames no
|
||||
// longer fit in 5 GB, which the old fixed 8-frame scratch term missed (#2570 review).
|
||||
assert!(memory_shortfall_with(300, 1920, 1080, 1_000_000_000, 16, true, 0).is_some());
|
||||
assert!(memory_shortfall_with(300, 1920, 1080, 5_000_000_000, 16, true, 0).is_some());
|
||||
assert_eq!(memory_shortfall_with(300, 1920, 1080, 6_000_000_000, 16, true, 0), None);
|
||||
// The two packed masks still grow with the range: a very long 1080p range cannot fit.
|
||||
assert!(memory_shortfall_with(1000, 1920, 1080, 500_000_000, 8, false, 0).is_some());
|
||||
// A RAM-backed spill volume counts its bytes as resident, so the same range that fit on
|
||||
// a disk-backed volume is refused (#2570 review).
|
||||
assert_eq!(memory_shortfall_with(30, 1920, 1080, 2_500_000_000, 4, false, 0), None);
|
||||
assert!(memory_shortfall_with(30, 1920, 1080, 2_500_000_000, 4, false, 2_000_000_000).is_some());
|
||||
// Hostile sizes saturate instead of overflowing.
|
||||
assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some());
|
||||
assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, 0).is_some());
|
||||
}
|
||||
|
||||
/// The native spill holds at most two f32 copies of the range beside the output at once (the
|
||||
/// membrane spill is gone before the propagated one is made), plus the PNGs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn scratch_space_is_checked_before_a_fill() {
|
||||
let copies = 300u64 * 1920 * 1080 * 32;
|
||||
assert_eq!(scratch_need(300, 1920, 1080), copies + copies / 10);
|
||||
let tmp = std::env::temp_dir();
|
||||
// A range that cannot possibly fit on the volume is refused up front.
|
||||
assert!(scratch_shortfall(usize::MAX, u32::MAX, u32::MAX, &tmp).is_some());
|
||||
// A relative folder that does not exist is resolved against the process directory.
|
||||
assert!(scratch_shortfall(usize::MAX, u32::MAX, u32::MAX, std::path::Path::new("effectcraft-missing-fill-folder")).is_some());
|
||||
// A tiny fill fits.
|
||||
assert_eq!(scratch_shortfall(1, 16, 16, &tmp), None);
|
||||
}
|
||||
|
||||
/// A fill that fails or is stopped after some frames were written deletes them, and the
|
||||
/// output folder it made; a finished fill keeps its files (#2570 review).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_stopped_fill_removes_its_partial_output() {
|
||||
use super::OutputFiles;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let write = |dir: &std::path::Path, made_dir: bool| {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let paths: Vec<String> = (0..2).map(|k| dir.join(format!("fill_{k:05}.png")).to_string_lossy().to_string()).collect();
|
||||
for p in &paths {
|
||||
std::fs::write(p, b"png").unwrap();
|
||||
}
|
||||
OutputFiles { dir: dir.to_path_buf(), made_dir, paths, spare: Vec::new(), kept: false }
|
||||
};
|
||||
// The fill made the folder: the files and the folder go.
|
||||
let made = root.path().join("made");
|
||||
drop(write(&made, true));
|
||||
assert!(!made.exists(), "the partial sequence and its folder are removed");
|
||||
// The folder was already there: only the fill's own files go.
|
||||
let existing = root.path().join("existing");
|
||||
drop(write(&existing, false));
|
||||
assert!(existing.exists());
|
||||
assert_eq!(std::fs::read_dir(&existing).unwrap().count(), 0);
|
||||
// Something else in a folder the fill made keeps the folder.
|
||||
let shared = root.path().join("shared");
|
||||
let files = write(&shared, true);
|
||||
std::fs::write(shared.join("notes.txt"), b"keep").unwrap();
|
||||
drop(files);
|
||||
assert_eq!(std::fs::read_dir(&shared).unwrap().count(), 1);
|
||||
// A file that was there before the fill wrote over it (an earlier sequence in a given
|
||||
// `outputDir`) is not the fill's to delete.
|
||||
let earlier = root.path().join("earlier");
|
||||
let mut files = write(&earlier, false);
|
||||
files.spare.push(files.paths[0].clone());
|
||||
drop(files);
|
||||
assert!(earlier.join("fill_00000.png").exists(), "an earlier fill's frame stays");
|
||||
assert!(!earlier.join("fill_00001.png").exists(), "the fill's own frame goes");
|
||||
// A finished fill keeps its files.
|
||||
let done = root.path().join("done");
|
||||
let kept = write(&done, true).keep();
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!(kept.iter().all(|p| std::path::Path::new(p).exists()));
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,14 @@ struct Isolated {
|
||||
image: Image,
|
||||
}
|
||||
|
||||
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
|
||||
/// `skip_hdr_step`: no HDR step, for a float output (ProEXR; [`RenderOpts::skip_hdr_step`]).
|
||||
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64, skip_hdr_step: bool) -> Image {
|
||||
let opts = RenderOpts {
|
||||
scale,
|
||||
backend: Backend::Auto,
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
draft_shadows: s.prefs.three_d.realtime_shadows,
|
||||
skip_hdr_step,
|
||||
..Default::default()
|
||||
};
|
||||
let mut r = Renderer::new(project, s.footage.as_ref(), opts);
|
||||
@@ -41,10 +43,11 @@ fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64
|
||||
r.comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// The comp frame and each visible layer rendered alone (top layer first).
|
||||
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<Isolated>)> {
|
||||
/// The comp frame and each visible layer rendered alone (top layer first). `skip_hdr_step`: as
|
||||
/// [`render_with`].
|
||||
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, skip_hdr_step: bool) -> Result<(Image, Vec<Isolated>)> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let full = render_with(s, &s.project, cid, t, scale);
|
||||
let full = render_with(s, &s.project, cid, t, scale, skip_hdr_step);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video());
|
||||
let ids: Vec<LayerId> = comp
|
||||
.layers
|
||||
@@ -72,7 +75,7 @@ fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<
|
||||
}
|
||||
}
|
||||
let Some((name, blend, opacity)) = info else { continue };
|
||||
let image = render_with(s, &p, cid, t, scale);
|
||||
let image = render_with(s, &p, cid, t, scale, skip_hdr_step);
|
||||
out.push(Isolated { name, blend, opacity, image });
|
||||
}
|
||||
Ok((full, out))
|
||||
@@ -111,7 +114,7 @@ fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> {
|
||||
pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, usize, u32, u32)> {
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::{WDoc, WLayer, write};
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
let (full, layers) = isolate(s, cid, t, scale, false)?;
|
||||
let mut doc = WDoc::new(full.width, full.height);
|
||||
let n = layers.len();
|
||||
// File order is bottom first.
|
||||
@@ -136,10 +139,6 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
|
||||
Ok((write(&doc), n, full.width, full.height))
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
|
||||
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
|
||||
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
@@ -158,15 +157,19 @@ fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
/// The multi-layer EXR bytes of a frame and the channel names.
|
||||
pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, Vec<String>, u32, u32)> {
|
||||
use exr::prelude::*;
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
// The composite and every layer without the HDR step, decoded with the output space's
|
||||
// curve (linear light in the output space's primaries).
|
||||
let (full, layers) = isolate(s, cid, t, scale, true)?;
|
||||
let dec = effectcraft_render::color::Pipe::of(&s.project.settings).output_linear();
|
||||
let (w, h) = (full.width as usize, full.height as usize);
|
||||
let planes = |img: &effectcraft_raster::Image| -> [Vec<f32>; 4] {
|
||||
let mut out: [Vec<f32>; 4] = Default::default();
|
||||
for p in &img.data {
|
||||
let sp = straight(*p);
|
||||
let a = sp[3];
|
||||
let lin = dec.apply([sp[0].max(0.0), sp[1].max(0.0), sp[2].max(0.0)]);
|
||||
for c in 0..3 {
|
||||
out[c].push(srgb_to_linear(sp[c].max(0.0)) * a);
|
||||
out[c].push(lin[c] * a);
|
||||
}
|
||||
out[3].push(a);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ fn render_project(host: &Session, path: &Path) -> Value {
|
||||
let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_)));
|
||||
Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items}))
|
||||
})();
|
||||
// The project shared `host`'s footage source and gave it its own set of footage effects read
|
||||
// the channels of (#474): give back the host's.
|
||||
host.footage.set_aux_footage(host.aux_footage.as_deref().unwrap_or(&[]));
|
||||
let mut v = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"state": "failed", "error": e, "items": []}),
|
||||
|
||||
@@ -255,6 +255,9 @@ pub struct Session {
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
|
||||
pub disk_salt: Option<(u64, u64)>,
|
||||
/// The footage files effects read the channels of, as last given to the footage source
|
||||
/// ([`Session::refresh_aux_footage`]); `None` = not given yet.
|
||||
pub aux_footage: Option<Vec<String>>,
|
||||
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
|
||||
/// is, settings that guard what scripts may do can't change (see
|
||||
/// [`prefs::Scripting::same_permissions`]).
|
||||
@@ -382,6 +385,7 @@ impl Default for Session {
|
||||
stored_paths: Default::default(),
|
||||
footage_stamps: Default::default(),
|
||||
disk_salt: None,
|
||||
aux_footage: None,
|
||||
script_calls: ScriptCalls::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ impl Session {
|
||||
self.saved_revision = self.revision;
|
||||
}
|
||||
self.refresh_disk_salt(false);
|
||||
self.refresh_aux_footage();
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
}
|
||||
|
||||
@@ -252,6 +253,8 @@ impl Session {
|
||||
self.project = Arc::new(p);
|
||||
// Its files may have changed since they were last stamped.
|
||||
self.disk_salt = None;
|
||||
// The set of footage effects read the channels of is sent again, even when it is empty.
|
||||
self.aux_footage = None;
|
||||
self.history = History::default();
|
||||
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
|
||||
self.path = path;
|
||||
|
||||
@@ -144,4 +144,43 @@ impl Session {
|
||||
self.layer_cache.clear();
|
||||
self.drop_crypto_probe();
|
||||
}
|
||||
|
||||
/// Tell the footage source which files effects read the channels of ([`aux_footage`]), so
|
||||
/// it can decode their pictures and channels at once from the first frame (#474). Called with
|
||||
/// every project change ([`Session::bump`]); the set is only sent when it changed, or after
|
||||
/// [`Session::replace_project`] cleared what was sent.
|
||||
pub(crate) fn refresh_aux_footage(&mut self) {
|
||||
let paths = aux_footage(&self.project);
|
||||
if self.aux_footage.as_ref() != Some(&paths) {
|
||||
self.footage.set_aux_footage(&paths);
|
||||
self.aux_footage = Some(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The files (each footage item's [`effectcraft_project::Footage::path`], sorted, once each) of
|
||||
/// the footage layers that have an enabled effect reading their channels
|
||||
/// ([`effectcraft_effects::reads_aux`]), with the layer's Effects switch on. Several items can
|
||||
/// use one file: it stays in while any of them is read.
|
||||
pub(crate) fn aux_footage(project: &effectcraft_project::Project) -> Vec<String> {
|
||||
use effectcraft_project::{GroupKind, ItemKind, LayerSource};
|
||||
let mut paths: Vec<String> = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
.filter(|l| l.switches.effects)
|
||||
.filter(|l| {
|
||||
l.effects()
|
||||
.is_some_and(|fx| fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::reads_aux(effect))))
|
||||
})
|
||||
.filter_map(|l| match &l.source {
|
||||
LayerSource::Footage { item } => match &project.item(*item)?.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(f.path.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
paths
|
||||
}
|
||||
@@ -207,6 +207,75 @@ pub fn disk_space(path: &std::path::Path) -> Option<(u64, u64)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is on a RAM-backed filesystem (`tmpfs`/`ramfs`), where bytes written count
|
||||
/// against memory as well as space. Linux reads `/proc/self/mountinfo`; other systems report
|
||||
/// `false` (their temporary folders are disk-backed by default). `path` may not exist yet: its
|
||||
/// nearest existing ancestor decides, as when the fill creates its scratch folder.
|
||||
pub fn is_memory_backed(path: &std::path::Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Some(canonical) = canonical_ancestor(path) else { return false };
|
||||
return std::fs::read_to_string("/proc/self/mountinfo").is_ok_and(|mountinfo| mountinfo_is_memory_backed(&mountinfo, &canonical));
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical path of `path`'s nearest existing ancestor (or itself). Relative paths are
|
||||
/// resolved against the current directory first, so a new relative output folder still names
|
||||
/// the volume it will be created on.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn canonical_ancestor(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) };
|
||||
let mut p = absolute.as_path();
|
||||
loop {
|
||||
if let Ok(canonical) = std::fs::canonicalize(p) {
|
||||
return Some(canonical);
|
||||
}
|
||||
p = p.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `/proc/self/mountinfo` listing mounts `path` on `tmpfs` or `ramfs`; the deepest
|
||||
/// matching mount point wins, and a later entry at the same mount point is the visible one.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn mountinfo_is_memory_backed(mountinfo: &str, path: &std::path::Path) -> bool {
|
||||
let mut best: Option<(usize, &str)> = None;
|
||||
for line in mountinfo.lines() {
|
||||
let Some(mount) = line.split_whitespace().nth(4) else { continue };
|
||||
let Some(fstype) = line.split(" - ").nth(1).and_then(|rest| rest.split_whitespace().next()) else { continue };
|
||||
let mount = unescape_mount(mount);
|
||||
if path.starts_with(&mount) && best.is_none_or(|(longest, _)| mount.len() >= longest) {
|
||||
best = Some((mount.len(), fstype));
|
||||
}
|
||||
}
|
||||
matches!(best, Some((_, "tmpfs" | "ramfs")))
|
||||
}
|
||||
|
||||
/// Undo mountinfo's octal escapes (`\040` space, `\011` tab, `\012` newline, `\134` backslash).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unescape_mount(field: &str) -> String {
|
||||
let mut out = String::with_capacity(field.len());
|
||||
let mut chars = field.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\'
|
||||
&& chars.clone().take(3).count() == 3
|
||||
&& let Ok(code) = u8::from_str_radix(&chars.clone().take(3).collect::<String>(), 8)
|
||||
{
|
||||
for _ in 0..3 {
|
||||
chars.next();
|
||||
}
|
||||
out.push(code as char);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CPU model name.
|
||||
pub fn cpu_name() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -339,4 +408,42 @@ mod tests {
|
||||
assert_eq!(m.available, 4100 * 16384);
|
||||
assert!(m.is_low());
|
||||
}
|
||||
|
||||
/// The default temp folder may be a `tmpfs`; a nested disk mount under `/tmp` or a nested
|
||||
/// `tmpfs` must be resolved to the deepest mount point.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn mountinfo_finds_memory_backed_storage() {
|
||||
let mountinfo = "\
|
||||
25 1 0:22 / / rw,relatime - ext4 /dev/nvme0n1p2 rw
|
||||
26 25 0:23 / /tmp rw,nosuid - tmpfs tmpfs rw
|
||||
27 25 0:24 / /tmp/disk rw - ext4 /dev/sda1 rw
|
||||
28 25 0:25 / /mnt/ram rw - ramfs none rw
|
||||
29 25 0:26 / /media/My\\040Disk rw - ext4 /dev/sdb1 rw
|
||||
";
|
||||
assert!(mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/tmp/effectcraft-fill")));
|
||||
assert!(!mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/tmp/disk/fill")));
|
||||
assert!(mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/mnt/ram/a")));
|
||||
assert!(!mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/home/fill")));
|
||||
assert!(!mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/")));
|
||||
// A path that merely shares a prefix is not on that mount.
|
||||
assert!(!mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/tmpdisk/fill")));
|
||||
// Escaped spaces in a mount point decode before matching.
|
||||
assert!(!mountinfo_is_memory_backed(mountinfo, std::path::Path::new("/media/My Disk/fill")));
|
||||
assert_eq!(unescape_mount("/media/My\\040Disk"), "/media/My Disk");
|
||||
// The later entry at the same mount point is the visible mount: a disk mount stacked on
|
||||
// a tmpfs is disk-backed, and the reverse order is memory-backed (#2570 review).
|
||||
let stacked_disk = "\
|
||||
30 25 0:30 / /tmp/stack rw - tmpfs tmpfs rw
|
||||
31 25 0:31 / /tmp/stack rw - ext4 /dev/sdb2 rw
|
||||
";
|
||||
let stacked_ram = "\
|
||||
30 25 0:30 / /tmp/stack rw - ext4 /dev/sdb2 rw
|
||||
31 25 0:31 / /tmp/stack rw - tmpfs tmpfs rw
|
||||
";
|
||||
assert!(!mountinfo_is_memory_backed(stacked_disk, std::path::Path::new("/tmp/stack/fill")));
|
||||
assert!(mountinfo_is_memory_backed(stacked_ram, std::path::Path::new("/tmp/stack/fill")));
|
||||
// A new relative folder resolves against the current directory before walking up.
|
||||
assert!(canonical_ancestor(std::path::Path::new("no-such-relative-folder/fill")).is_some());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod add_item_options;
|
||||
mod aep_import;
|
||||
mod anim_presets;
|
||||
mod anim_tools;
|
||||
mod aux_footage;
|
||||
mod blender;
|
||||
mod camera_track;
|
||||
mod codec_options;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The footage files effects read the channels of (#474): the session gives the footage source
|
||||
//! the whole set with every project change ([`Session::bump`]), so a multi-layer OpenEXR read by
|
||||
//! EXtractoR or Cryptomatte decodes its picture and channels at once from the first frame, and
|
||||
//! goes back to its picture alone once no effect reads it.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
/// Records every set it is given, and the set it had when it was first asked for a frame.
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
sets: Mutex<Vec<Vec<String>>>,
|
||||
at_first_frame: Mutex<Option<Option<Vec<String>>>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
fn last(&self) -> Option<Vec<String>> {
|
||||
self.sets.lock().unwrap().last().cloned()
|
||||
}
|
||||
fn calls(&self) -> usize {
|
||||
self.sets.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl FootageSource for Recorder {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.at_first_frame.lock().unwrap().get_or_insert_with(|| self.last());
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
self.sets.lock().unwrap().push(paths.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Still,
|
||||
width: 8,
|
||||
height: 4,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session(rec: &Arc<Recorder>) -> Session {
|
||||
let mut s = Session { footage: rec.clone(), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, path: &str) -> serde_json::Value {
|
||||
s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone()
|
||||
}
|
||||
|
||||
/// Add a layer of `item` to the active comp; its layer id.
|
||||
fn add_layer(s: &mut Session, item: &serde_json::Value) -> u64 {
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.state.selected_layers[0].0
|
||||
}
|
||||
|
||||
fn paths(v: &[&str]) -> Option<Vec<String>> {
|
||||
Some(v.iter().map(|p| p.to_string()).collect())
|
||||
}
|
||||
|
||||
/// Adding, disabling, removing an effect and undoing or redoing each change the set; edits that
|
||||
/// don't change it don't send it again.
|
||||
#[test]
|
||||
fn effect_edits_undo_and_redo_update_the_set() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut s = session(&rec);
|
||||
let item = import(&mut s, "/renders/a.exr");
|
||||
let l = add_layer(&mut s, &item);
|
||||
assert_eq!(rec.last(), paths(&[]), "no effect reads it yet");
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur", "layers": [l]})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&[]), "a blur doesn't read channels");
|
||||
s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [l]})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
|
||||
assert!(s.undo());
|
||||
assert_eq!(rec.last(), paths(&[]), "undoing the add");
|
||||
assert!(s.redo());
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "redoing it");
|
||||
let calls = rec.calls();
|
||||
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "Gaussian Blur", "value": false})).unwrap();
|
||||
assert_eq!(rec.calls(), calls, "the set didn't change: it isn't sent again");
|
||||
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "EXtractoR", "value": false})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&[]), "a disabled effect reads nothing");
|
||||
s.execute_checked("effect.toggle", json!({"layer": l, "effect": "EXtractoR", "value": true})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
|
||||
s.execute_checked("effect.remove", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&[]), "removed");
|
||||
assert!(s.undo());
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "undoing the removal");
|
||||
assert!(s.redo());
|
||||
assert_eq!(rec.last(), paths(&[]), "redoing it");
|
||||
assert!(s.undo());
|
||||
s.execute_checked("effect.duplicate", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
|
||||
s.execute_checked("effect.remove", json!({"layer": l, "effect": "EXtractoR"})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "the duplicate still reads it");
|
||||
}
|
||||
|
||||
/// Opening a project gives the set before the first frame is rendered; opening another sends
|
||||
/// it again even when it is empty.
|
||||
#[test]
|
||||
fn opening_a_project_gives_the_set_before_the_first_frame() {
|
||||
let made = Arc::new(Recorder::default());
|
||||
let mut s = session(&made);
|
||||
let item = import(&mut s, "/renders/crypto.exr");
|
||||
let l = add_layer(&mut s, &item);
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte", "layers": [l]})).unwrap();
|
||||
let project: Project = (*s.project).clone();
|
||||
let comp = s.state.active_comp.unwrap();
|
||||
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut s = Session { footage: rec.clone(), ..Default::default() };
|
||||
s.replace_project(project, None);
|
||||
assert_eq!(rec.last(), paths(&["/renders/crypto.exr"]));
|
||||
s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(*rec.at_first_frame.lock().unwrap(), Some(paths(&["/renders/crypto.exr"])), "given before the first frame");
|
||||
s.replace_project(Project::default(), None);
|
||||
assert_eq!(rec.last(), paths(&[]));
|
||||
let calls = rec.calls();
|
||||
s.replace_project(Project::default(), None);
|
||||
assert_eq!(rec.calls(), calls + 1, "each opened project gets its set");
|
||||
}
|
||||
|
||||
/// Two footage items of one file: the file stays in while an effect reads either of them.
|
||||
#[test]
|
||||
fn two_items_of_one_file() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut s = session(&rec);
|
||||
let a = import(&mut s, "/renders/shared.exr");
|
||||
let f = match &s.project.item(ItemId(a.as_u64().unwrap())).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let b = s.edit("Add", None, |p, _| Ok(p.add_item("shared copy", crate::color::Label::None, None, ItemKind::Footage(f)))).unwrap();
|
||||
let la = add_layer(&mut s, &a);
|
||||
let lb = add_layer(&mut s, &json!(b.0));
|
||||
s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [la]})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "ID Matte", "layers": [lb]})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/shared.exr"]), "once");
|
||||
s.execute_checked("effect.remove", json!({"layer": la, "effect": "EXtractoR"})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/shared.exr"]), "the other item still reads it");
|
||||
s.execute_checked("effect.remove", json!({"layer": lb, "effect": "ID Matte"})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&[]));
|
||||
}
|
||||
|
||||
/// Replacing a layer's source or an item's file moves the set to the new file.
|
||||
#[test]
|
||||
fn source_and_footage_replacement_follow() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut s = session(&rec);
|
||||
let a = import(&mut s, "/renders/a.exr");
|
||||
let b = import(&mut s, "/renders/b.exr");
|
||||
let l = add_layer(&mut s, &a);
|
||||
s.execute_checked("effect.apply", json!({"effect": "Depth Matte", "layers": [l]})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]));
|
||||
s.execute_checked("layer.replaceSource", json!({"layers": [l], "item": b})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/b.exr"]), "the layer's new source");
|
||||
s.execute_checked("file.replaceFootage", json!({"item": b, "path": "/renders/c.exr"})).unwrap();
|
||||
assert_eq!(rec.last(), paths(&["/renders/c.exr"]), "the item's new file");
|
||||
}
|
||||
|
||||
/// A watch-folder render opens its project in a session sharing the host's footage source: the
|
||||
/// host's set is given back afterwards.
|
||||
#[test]
|
||||
fn a_watch_folder_render_gives_the_host_set_back() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-aux-footage-watch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut other = Session::default();
|
||||
other.execute("comp.new", json!({"name": "Idle", "width": 8, "height": 8, "duration": 1})).unwrap();
|
||||
other.execute("file.saveAs", json!({"path": dir.join("idle.ecproj").to_string_lossy()})).unwrap();
|
||||
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut s = session(&rec);
|
||||
let item = import(&mut s, "/renders/a.exr");
|
||||
let l = add_layer(&mut s, &item);
|
||||
s.execute_checked("effect.apply", json!({"effect": "EXtractoR", "layers": [l]})).unwrap();
|
||||
let calls = rec.calls();
|
||||
let r = s.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["rendered"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(rec.calls() > calls + 1, "the watched project gave its own set");
|
||||
assert_eq!(rec.last(), paths(&["/renders/a.exr"]), "the host's set is back");
|
||||
s.execute("file.watchFolder", json!({"stop": true})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -230,3 +230,131 @@ fn save_frame_as_queued_respects_frame_default_template() {
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
|
||||
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ *b as u64).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
/// A 32×32 comp in a managed project (sRGB working space, linearized): a full-frame over-range
|
||||
/// solid under a 16×16 in-range Multiply solid at 50%.
|
||||
fn hdr_frame(bit_depth: u64, hdr: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": bit_depth, "workingSpace": "srgb", "linearize": true, "hdr": hdr})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bright", "color": [4.0, 2.0, 0.5]})).unwrap();
|
||||
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": [0.85, 0.9, 0.2], "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [box_], "mode": "Multiply"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": box_, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Photoshop Layers keeps the HDR step (it is a display-referred output): its bytes for each bit
|
||||
/// depth × HDR mode.
|
||||
#[test]
|
||||
fn photoshop_layers_bytes_are_unchanged() {
|
||||
let dir = out_dir("frame-psd-golden");
|
||||
let mut got = vec![];
|
||||
for depth in [8, 16, 32] {
|
||||
for hdr in ["clip", "compand", "toneMap"] {
|
||||
let mut s = hdr_frame(depth, hdr);
|
||||
let path = dir.join(format!("{depth}-{hdr}.psd")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
|
||||
got.push(fnv(&std::fs::read(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
// The hashes before ProEXR skipped the HDR step.
|
||||
let want: [u64; 9] = [
|
||||
0x3b921d4d9fc3119f,
|
||||
0x1886d026edf36a7f,
|
||||
0x6b564aa7fb50139f,
|
||||
0xabd585992079fc9f,
|
||||
0x88ca38726eaa557f,
|
||||
0xa3636606d7a4927f,
|
||||
0x7385ed211e9815ff,
|
||||
0x7385ed211e9815ff,
|
||||
0xfb59fa3f3bdfac3f,
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// Every sample of a ProEXR file, channels in name order.
|
||||
fn proexr_samples(path: &str) -> Vec<(String, Vec<f32>)> {
|
||||
use exr::prelude::*;
|
||||
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(path).unwrap();
|
||||
let mut out: Vec<(String, Vec<f32>)> = img.layer_data[0]
|
||||
.channel_data
|
||||
.list
|
||||
.iter()
|
||||
.map(|c| match &c.sample_data {
|
||||
FlatSamples::F32(v) => (c.name.to_string(), v.clone()),
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// ProEXR decodes with the output space's own curve. A Rec. 709 output has the sRGB working
|
||||
/// space's primaries but another curve, so the composite and the isolated layer must hold the
|
||||
/// solid's light (its sRGB working value decoded), not the sRGB decode of its Rec. 709 encoding.
|
||||
#[test]
|
||||
fn proexr_decodes_a_non_srgb_output_curve() {
|
||||
let dir = out_dir("frame-exr-rec709");
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "srgb", "linearize": true, "outputSpace": "rec709"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Warm", "color": [0.8, 0.5, 0.2]})).unwrap();
|
||||
let path = dir.join("rec709.exr").to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let got = proexr_samples(&path);
|
||||
let at = |n: &str| got.iter().find(|c| c.0 == n).unwrap_or_else(|| panic!("no {n}")).1[0];
|
||||
let lin = effectcraft_color::srgb_to_linear;
|
||||
for (n, want) in [("R", lin(0.8)), ("G", lin(0.5)), ("B", lin(0.2)), ("Warm.R", lin(0.8)), ("Warm.B", lin(0.2))] {
|
||||
assert!((at(n) - want).abs() <= 1e-3 * want, "{n}: {} vs {want}", at(n));
|
||||
}
|
||||
}
|
||||
|
||||
/// ProEXR samples in Clip mode (sRGB output) for each bit depth.
|
||||
#[test]
|
||||
fn proexr_clip_samples_are_unchanged() {
|
||||
let dir = out_dir("frame-exr-clip-golden");
|
||||
let mut got = vec![];
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = hdr_frame(depth, "clip");
|
||||
let path = dir.join(format!("{depth}.exr")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let bytes: Vec<u8> =
|
||||
proexr_samples(&path).iter().flat_map(|(n, v)| n.bytes().chain(v.iter().flat_map(|x| x.to_bits().to_le_bytes())).collect::<Vec<u8>>()).collect();
|
||||
got.push(fnv(&bytes));
|
||||
}
|
||||
// The hashes before ProEXR skipped the HDR step.
|
||||
let want: [u64; 3] = [0x4b9ecc87e6db7d03, 0xe06a35d302e71503, 0x694ca552f3efc903];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// ProEXR ignores compand and tone mapping: the composite and every isolated layer hold the Clip
|
||||
/// mode's samples, and over-range values stay.
|
||||
#[test]
|
||||
fn proexr_layers_are_not_tone_mapped() {
|
||||
let dir = out_dir("frame-exr-hdr");
|
||||
let samples = |depth: u64, hdr: &str| {
|
||||
let mut s = hdr_frame(depth, hdr);
|
||||
let path = dir.join(format!("{depth}-{hdr}.exr")).to_string_lossy().to_string();
|
||||
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
proexr_samples(&path)
|
||||
};
|
||||
for depth in [8, 16, 32] {
|
||||
let clip = samples(depth, "clip");
|
||||
assert!(clip.iter().any(|(n, _)| n == "Bright.R") && clip.iter().any(|(n, _)| n == "Box.R"), "{:?}", clip.iter().map(|c| &c.0).collect::<Vec<_>>());
|
||||
for hdr in ["compand", "toneMap"] {
|
||||
assert!(samples(depth, hdr) == clip, "{depth} bpc {hdr}");
|
||||
}
|
||||
}
|
||||
let tone = samples(32, "toneMap");
|
||||
let at = |n: &str| tone.iter().find(|c| c.0 == n).unwrap().1[0];
|
||||
// The solid's colour is sRGB-encoded in the working space (4.0 is about 25.3 in linear light).
|
||||
let lin = effectcraft_color::srgb_to_linear;
|
||||
for (n, want) in [("Bright.R", lin(4.0)), ("Bright.G", lin(2.0)), ("Bright.B", lin(0.5)), ("R", lin(4.0))] {
|
||||
assert!((at(n) - want).abs() <= 1e-4 * want, "{n}: {} vs {want}", at(n));
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,58 @@ fn content_aware_fill_removes_an_object() {
|
||||
assert!(s2.execute("contentFill.generate", json!({"wait": true, "outputDir": tmp("caf2").to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
/// A scratch folder that cannot be created surfaces as an error, not a cancelled fill or a
|
||||
/// black frame (#2570 review).
|
||||
#[test]
|
||||
fn content_aware_fill_reports_a_spill_failure() {
|
||||
let (mut s, _, _, _) = setup(48, 32, 0.2, |f| {
|
||||
let mut img = Image::new(48, 32);
|
||||
for y in 0..32 {
|
||||
for x in 0..48 {
|
||||
img.set(x, y, bg(x as f32 + f as f32, y as f32));
|
||||
}
|
||||
}
|
||||
img
|
||||
});
|
||||
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
|
||||
let dir = tmp("cafspill");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("not-a-folder");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
// The fill wants to create its spill here; the parent is a file.
|
||||
let out = file.join("fills");
|
||||
let e = s.execute("contentFill.generate", json!({"method": "object", "outputDir": out.to_string_lossy(), "wait": true})).unwrap_err();
|
||||
// The OS wording differs ("Not a directory" on Unix, "Cannot create a file when that file
|
||||
// already exists" on Windows), so look for the fill's own marker.
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("spill: cannot make the scratch file"), "not a spill error: {msg}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A fill plan that comes back without files or frames (a job worker that wrote none) is an
|
||||
/// error, not a crash on the first file (#2570 review).
|
||||
#[test]
|
||||
fn content_aware_fill_without_files_is_an_error() {
|
||||
let (mut s, cid, lid, _) = setup(48, 32, 0.2, |_| Image::filled(48, 32, [0.5, 0.5, 0.5, 1.0]));
|
||||
let plan = crate::commands::content_fill::FillPlan {
|
||||
comp: cid,
|
||||
layer: lid,
|
||||
name: "Clip".into(),
|
||||
width: 48,
|
||||
height: 32,
|
||||
times: vec![Tick::ZERO],
|
||||
method: "object".into(),
|
||||
lighting: 0.0,
|
||||
expand: 0,
|
||||
reference: None,
|
||||
dir: "Clip_Fill_1".into(),
|
||||
};
|
||||
assert!(crate::commands::content_fill::apply_plan(&mut s, &plan, vec![]).is_err());
|
||||
let empty = crate::commands::content_fill::FillPlan { times: vec![], ..plan };
|
||||
assert!(crate::commands::content_fill::apply_plan(&mut s, &empty, vec!["Clip_Fill_1/fill_00000.png".into()]).is_err());
|
||||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), 1, "no fill layer was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_fill_runs_in_the_background() {
|
||||
let (mut s, _, _, _) = setup(48, 32, 0.2, |f| {
|
||||
|
||||
@@ -316,25 +316,25 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
|
||||
OutputFormat::ExrSequence => {
|
||||
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
|
||||
// Colour is floored at 0 but not capped at 1: a 32 bpc frame's over-range values survive (#339).
|
||||
let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
|
||||
// The frame was rendered without the HDR step (`RenderOpts::skip_hdr_step`) and is
|
||||
// decoded with the output space's curve, in the output space's primaries.
|
||||
let dec = effectcraft_render::color::Pipe::of(&cx.project.settings).output_linear();
|
||||
let lin3 = |c: [f32; 3]| dec.apply(c.map(|v| v.max(0.0)));
|
||||
let mut data = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
|
||||
let (c, a) = match channels {
|
||||
Channels::Alpha => {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
([lin(a); 3], 1.0)
|
||||
}
|
||||
// Alpha is coverage, not encoded colour: written as it is.
|
||||
Channels::Alpha => ([p[3].clamp(0.0, 1.0); 3], 1.0),
|
||||
Channels::Rgba => {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let s = |c: f32| if a > 0.0 { lin((c / a).max(0.0)) * a } else { 0.0 };
|
||||
([s(p[0]), s(p[1]), s(p[2])], a)
|
||||
let c = if a > 0.0 { lin3([p[0] / a, p[1] / a, p[2] / a]).map(|v| v * a) } else { [0.0; 3] };
|
||||
(c, a)
|
||||
}
|
||||
Channels::Rgb => {
|
||||
let k = 1.0 - p[3].clamp(0.0, 1.0);
|
||||
let c = |i: usize| lin((p[i] + comp.background[i] * k).max(0.0));
|
||||
([c(0), c(1), c(2)], 1.0)
|
||||
(lin3([0, 1, 2].map(|i| p[i] + comp.background[i] * k)), 1.0)
|
||||
}
|
||||
};
|
||||
data.extend_from_slice(&[c[0], c[1], c[2], a]);
|
||||
|
||||
@@ -251,6 +251,8 @@ impl<'a> Cx<'a> {
|
||||
roi: None,
|
||||
nested_switches: self.job.nested_switches,
|
||||
draft_shadows: true,
|
||||
// OpenEXR keeps over-range values: no HDR compand / tone mapping on its frames.
|
||||
skip_hdr_step: self.job.output.format == effectcraft_project::render_queue::OutputFormat::ExrSequence,
|
||||
proxy: s.proxy_use,
|
||||
};
|
||||
let mut r = Renderer::new(&self.project, self.job.footage, opts);
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! OpenEXR output holds the comp's values in linear light without Project Settings ▸ HDR
|
||||
//! (compand / tone map), and decodes with the output space's own curve; 8-bit writers keep the
|
||||
//! HDR step and their exact bytes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion, Label};
|
||||
use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_project::{BitDepth, Comp, HdrMode, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 48;
|
||||
const H: u32 = 16;
|
||||
/// Three nested bands, outermost first: in range, inside the compand knee, over range.
|
||||
const BANDS: [[f32; 3]; 3] = [[0.2, 0.5, 0.9], [0.85, 0.9, 0.95], [4.0, 2.0, 0.5]];
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-exr-scene-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// A one-frame comp of [`BANDS`] (full width, 2/3, 1/3, centred) with the given colour settings.
|
||||
fn project(depth: BitDepth, hdr: HdrMode, ws: Option<ColorSpace>, linearize: bool, out: Option<ColorSpace>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
p.settings.hdr = hdr;
|
||||
p.settings.working_space = ws;
|
||||
p.settings.linearize = linearize;
|
||||
p.settings.output_space = out;
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
|
||||
comp.background = [0.0, 0.0, 0.0];
|
||||
for (i, color) in BANDS.into_iter().enumerate() {
|
||||
let w = W - W / 3 * i as u32;
|
||||
let sid = p.add_item(&format!("Band {i}"), Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: H, pixel_aspect: 1.0 }));
|
||||
let l = build::layer(&mut p, &comp, &format!("Band {i}"), LayerSource::Solid { item: sid }, (w, H), None);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// A comp builder: bit depth, HDR mode, working space, Linearize Working Space, output space.
|
||||
type Build = fn(BitDepth, HdrMode, Option<ColorSpace>, bool, Option<ColorSpace>) -> (Project, ItemId);
|
||||
|
||||
/// The half-opacity layer of [`half_project`]: over range in red.
|
||||
const HALF: [f32; 3] = [4.0, 0.6, 0.3];
|
||||
|
||||
/// A one-frame comp over a coloured background whose only layer, [`HALF`] at 50% opacity,
|
||||
/// covers the left two thirds: semi-transparent and transparent pixels (the premultiplied and
|
||||
/// RGB-over-background paths of the writers).
|
||||
fn half_project(depth: BitDepth, hdr: HdrMode, ws: Option<ColorSpace>, linearize: bool, out: Option<ColorSpace>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
p.settings.hdr = hdr;
|
||||
p.settings.working_space = ws;
|
||||
p.settings.linearize = linearize;
|
||||
p.settings.output_space = out;
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
|
||||
comp.background = [0.2, 0.3, 0.4];
|
||||
let w = W / 3 * 2;
|
||||
let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: HALF, width: w, height: H, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (w, H), None);
|
||||
l.props.prop_mut("transform/position").expect("position").value = effectcraft_keyframe::Value::Vec3([w as f64 / 2.0, H as f64 / 2.0, 0.0]);
|
||||
l.props.prop_mut("transform/opacity").expect("opacity").value = effectcraft_keyframe::Value::Scalar(50.0);
|
||||
comp.layers.push(l);
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Export the comp's one frame as a `fmt` sequence; the frame file's path.
|
||||
fn render(p: &Project, cid: ItemId, fmt: OutputFormat, channels: Channels, dir: &Path, ext: &str) -> PathBuf {
|
||||
let mut om = OutputModule::for_format(fmt);
|
||||
om.channels = channels;
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: &om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
dir.join(format!("f_00000.{ext}"))
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ *b as u64).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
const DEPTHS: [BitDepth; 3] = [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32];
|
||||
const MODES: [HdrMode; 3] = [HdrMode::Clip, HdrMode::Compand, HdrMode::ToneMap];
|
||||
const STILLS: [(OutputFormat, Channels, &str); 3] = [
|
||||
(OutputFormat::PngSequence, Channels::Rgba, "png"),
|
||||
(OutputFormat::TiffSequence, Channels::Rgb, "tif"),
|
||||
(OutputFormat::TargaSequence, Channels::Rgba, "tga"),
|
||||
];
|
||||
|
||||
/// The bytes of every 8-bit still for each bit depth × HDR mode, in a managed linear sRGB
|
||||
/// project with a Display P3 output.
|
||||
fn still_hashes(build: Build) -> Vec<u64> {
|
||||
let mut out = vec![];
|
||||
for depth in DEPTHS {
|
||||
for mode in MODES {
|
||||
let (p, cid) = build(depth, mode, Some(ColorSpace::Srgb), true, Some(ColorSpace::DisplayP3));
|
||||
for (fmt, ch, ext) in STILLS {
|
||||
let d = out_dir(&format!("golden-{depth:?}-{mode:?}-{ext}"));
|
||||
let bytes = std::fs::read(render(&p, cid, fmt, ch, &d, ext)).expect("read");
|
||||
out.push(fnv(&bytes));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`still_hashes`] before float outputs skipped the HDR step: PNG, TIFF and Targa (8-bit
|
||||
/// writers, which keep compand / tone mapping) are byte for byte the same.
|
||||
const STILL_GOLDEN: [u64; 27] = [
|
||||
0xd0cf67f45d6b6b68,
|
||||
0x712d42f3e204cbdb,
|
||||
0x021dc457f259eb87,
|
||||
0x88a16b1ca2db8805,
|
||||
0xc6fb221b89179bdb,
|
||||
0x965636561aaabb87,
|
||||
0x85c831b7694662db,
|
||||
0x8875a5c5232a33db,
|
||||
0x42d8f3c110bd0987,
|
||||
0xd555a6850fe66f04,
|
||||
0x920b49ee0f9900e4,
|
||||
0x94e4f43742f2c8ce,
|
||||
0xab0792254da43348,
|
||||
0x647a60c0a33237ec,
|
||||
0x65cba80bc3ecff08,
|
||||
0x0726be1b60eb7276,
|
||||
0xffd0315633cb88db,
|
||||
0x4f6a13131fb7a175,
|
||||
0x07a706a7e100b51d,
|
||||
0x718600eaa65a6f80,
|
||||
0x53489230b732f638,
|
||||
0x13024d498aa2552c,
|
||||
0x3a3dae30c9e25f83,
|
||||
0xc3191a3e860aa0c5,
|
||||
0xe5d7d08ecc3ddece,
|
||||
0x29a748f729fe5c8a,
|
||||
0x78d3b9aa9e107774,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn eight_bit_stills_are_unchanged() {
|
||||
assert_eq!(still_hashes(project), STILL_GOLDEN);
|
||||
assert_eq!(still_hashes(half_project), HALF_STILL_GOLDEN);
|
||||
}
|
||||
|
||||
/// [`still_hashes`] of [`half_project`] before: premultiplied and RGB-over-background stills.
|
||||
const HALF_STILL_GOLDEN: [u64; 27] = [
|
||||
0x3abd611ee63b7edd,
|
||||
0x51e7ec0426773cdb,
|
||||
0xbee34abe9f0a3d87,
|
||||
0x554c03dba915d290,
|
||||
0xe98490fc826a7adb,
|
||||
0x0a0e77927bf1a187,
|
||||
0x4f1c076634822d50,
|
||||
0xe790f474e05b3adb,
|
||||
0xe517c3136471a187,
|
||||
0x07cd5e73765539f9,
|
||||
0x68e51bbd8d442a64,
|
||||
0x844a5f23a9cc9a3d,
|
||||
0xc302b2b86f4cdd70,
|
||||
0xb7247795d204a2c5,
|
||||
0x3b18867537c69265,
|
||||
0x9a5f7679bb5a2399,
|
||||
0x868b729dcc804656,
|
||||
0x2e0bc64a3fb562f0,
|
||||
0x6dc9e1a1eac1220d,
|
||||
0x6e101cd849e78f32,
|
||||
0x7b7a2a776f73d70b,
|
||||
0x6b3244f808c65d4e,
|
||||
0xbfa1b35fada2c77d,
|
||||
0x8c3a31415ed47e33,
|
||||
0xac787507caf1c896,
|
||||
0x4a38297060de19ff,
|
||||
0x300d6e4222eaa5b5,
|
||||
];
|
||||
|
||||
/// The decoded samples of a still EXR frame, row-major RGBA.
|
||||
fn exr_samples(path: &Path) -> Vec<f32> {
|
||||
image::open(path).expect("exr").to_rgba32f().into_raw()
|
||||
}
|
||||
|
||||
fn bits_hash(v: &[f32]) -> u64 {
|
||||
fnv(&v.iter().flat_map(|x| x.to_bits().to_le_bytes()).collect::<Vec<u8>>())
|
||||
}
|
||||
|
||||
/// Clip-mode EXR samples with an sRGB output, for each colour setup × bit depth × channels.
|
||||
fn exr_clip_hashes(build: Build, channels: &[Channels]) -> Vec<u64> {
|
||||
let mut out = vec![];
|
||||
for (ws, linearize) in [(None, false), (Some(ColorSpace::Srgb), false), (Some(ColorSpace::Srgb), true), (Some(ColorSpace::AcesCg), true)] {
|
||||
for depth in DEPTHS {
|
||||
let (p, cid) = build(depth, HdrMode::Clip, ws, linearize, None);
|
||||
for &ch in channels {
|
||||
let d = out_dir(&format!("clip-{ws:?}-{linearize}-{depth:?}-{ch:?}"));
|
||||
out.push(bits_hash(&exr_samples(&render(&p, cid, OutputFormat::ExrSequence, ch, &d, "exr"))));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`exr_clip_hashes`] before: Clip-mode EXRs with an sRGB output keep every sample.
|
||||
const EXR_CLIP_GOLDEN: [u64; 36] = [
|
||||
0x9f54fea3d3abcb25,
|
||||
0x9f54fea3d3abcb25,
|
||||
0x7d40ad9df8600325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x7d40ad9df8600325,
|
||||
0xb60de6a90263b525,
|
||||
0xb60de6a90263b525,
|
||||
0x7d40ad9df8600325,
|
||||
0x9f54fea3d3abcb25,
|
||||
0x9f54fea3d3abcb25,
|
||||
0x7d40ad9df8600325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x7d40ad9df8600325,
|
||||
0xb60de6a90263b525,
|
||||
0xb60de6a90263b525,
|
||||
0x7d40ad9df8600325,
|
||||
0x27858400549ac325,
|
||||
0x27858400549ac325,
|
||||
0x7d40ad9df8600325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x379f5ef9e35ce325,
|
||||
0x7d40ad9df8600325,
|
||||
0xb60de6a90263b525,
|
||||
0xb60de6a90263b525,
|
||||
0x7d40ad9df8600325,
|
||||
0x31d03697119d9325,
|
||||
0x31d03697119d9325,
|
||||
0x7d40ad9df8600325,
|
||||
0xff61dc513c834b25,
|
||||
0xff61dc513c834b25,
|
||||
0x7d40ad9df8600325,
|
||||
0x0c57d24a44f45925,
|
||||
0x0c57d24a44f45925,
|
||||
0x7d40ad9df8600325,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn exr_clip_with_srgb_output_is_unchanged() {
|
||||
// The bands are opaque everywhere, so the Alpha-only files (alpha 1.0) are unchanged too.
|
||||
assert_eq!(exr_clip_hashes(project, &[Channels::Rgba, Channels::Rgb, Channels::Alpha]), EXR_CLIP_GOLDEN);
|
||||
assert_eq!(exr_clip_hashes(half_project, &[Channels::Rgba, Channels::Rgb]), HALF_EXR_CLIP_GOLDEN);
|
||||
}
|
||||
|
||||
/// [`exr_clip_hashes`] of [`half_project`] before (RGBA premultiplied, RGB over the background).
|
||||
const HALF_EXR_CLIP_GOLDEN: [u64; 24] = [
|
||||
0xe7f7d517df7baf25,
|
||||
0xc9d6dcb2671f9925,
|
||||
0x2fe5733c26d16325,
|
||||
0x8b267197d2645925,
|
||||
0x6fe736332cb80f25,
|
||||
0x30d5f66395d92d25,
|
||||
0xe7f7d517df7baf25,
|
||||
0xc9d6dcb2671f9925,
|
||||
0x2fe5733c26d16325,
|
||||
0x8b267197d2645925,
|
||||
0x6fe736332cb80f25,
|
||||
0x30d5f66395d92d25,
|
||||
0x8c261c1cd9674b25,
|
||||
0xace550c3a8e72925,
|
||||
0xb9bd0b7863b78325,
|
||||
0xdf8e948c5e8af125,
|
||||
0x6fe736332cb80f25,
|
||||
0x30d5f66395d92d25,
|
||||
0xe4aedfefacc80725,
|
||||
0x95ddfccddc885525,
|
||||
0x81fa3c39c1d09725,
|
||||
0x49cc0014f05a1d25,
|
||||
0x4ed1b417cc847725,
|
||||
0x2df6df5ce394d925,
|
||||
];
|
||||
|
||||
/// An Alpha-only EXR holds the alpha (coverage) as it is in every output space: no transfer
|
||||
/// curve is decoded from it (it used to be sRGB-decoded, 0.5 → 0.214).
|
||||
#[test]
|
||||
fn exr_alpha_only_is_raw_alpha() {
|
||||
for out in [None, Some(ColorSpace::Rec709), Some(ColorSpace::Rec2100Pq), Some(ColorSpace::AcesCg)] {
|
||||
for ws in [None, Some(ColorSpace::Srgb)] {
|
||||
let (p, cid) = half_project(BitDepth::Bpc32, HdrMode::Clip, ws, false, out);
|
||||
let d = out_dir(&format!("alpha-{ws:?}-{out:?}"));
|
||||
let v = exr_samples(&render(&p, cid, OutputFormat::ExrSequence, Channels::Alpha, &d, "exr"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let at = |x: u32| &v[((H / 2 * W + x) * 4) as usize..][..4];
|
||||
assert_eq!(at(2), [0.5, 0.5, 0.5, 1.0], "{ws:?} {out:?} covered");
|
||||
assert_eq!(at(W - 2), [0.0, 0.0, 0.0, 1.0], "{ws:?} {out:?} transparent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compand and tone mapping leave EXR output alone: the file holds the Clip mode's samples, for
|
||||
/// every bit depth, channel set and colour setup (wide working spaces, a Display P3 output).
|
||||
#[test]
|
||||
fn exr_ignores_compand_and_tone_mapping() {
|
||||
let setups = [
|
||||
(Some(ColorSpace::Srgb), true, None),
|
||||
(Some(ColorSpace::Srgb), false, Some(ColorSpace::DisplayP3)),
|
||||
(Some(ColorSpace::AcesCg), true, None),
|
||||
(Some(ColorSpace::Rec2020), true, Some(ColorSpace::Rec709)),
|
||||
];
|
||||
for (ws, linearize, out) in setups {
|
||||
for depth in DEPTHS {
|
||||
for (ch, (name, build)) in
|
||||
[Channels::Rgba, Channels::Rgb].into_iter().flat_map(|c| [(c, ("bands", project as Build)), (c, ("half", half_project as Build))])
|
||||
{
|
||||
let samples = |mode: HdrMode| {
|
||||
let (p, cid) = build(depth, mode, ws, linearize, out);
|
||||
let d = out_dir(&format!("modes-{name}-{ws:?}-{linearize}-{out:?}-{depth:?}-{ch:?}-{mode:?}"));
|
||||
let v = exr_samples(&render(&p, cid, OutputFormat::ExrSequence, ch, &d, "exr"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
v
|
||||
};
|
||||
let clip = samples(HdrMode::Clip);
|
||||
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
|
||||
assert!(samples(mode) == clip, "{name} {ws:?} {linearize} {out:?} {depth:?} {ch:?} {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre pixel (the innermost band) of a one-frame EXR render.
|
||||
fn exr_centre(p: &Project, cid: ItemId, name: &str) -> [f32; 4] {
|
||||
let d = out_dir(name);
|
||||
let v = exr_samples(&render(p, cid, OutputFormat::ExrSequence, Channels::Rgba, &d, "exr"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let o = ((H / 2 * W + W / 2) * 4) as usize;
|
||||
[v[o], v[o + 1], v[o + 2], v[o + 3]]
|
||||
}
|
||||
|
||||
fn assert_near(got: [f32; 4], want: [f32; 3], what: &str) {
|
||||
for i in 0..3 {
|
||||
assert!((got[i] - want[i]).abs() <= 1e-4 * want[i].abs().max(1.0), "{what}: channel {i}: got {got:?}, want {want:?}");
|
||||
}
|
||||
assert_eq!(got[3], 1.0, "{what}: {got:?}");
|
||||
}
|
||||
|
||||
/// In a 32 bpc linear project with compand or tone mapping, the EXR holds the comp's scene-linear
|
||||
/// values in the output space's primaries: 4.0 stays 4.0. (Solid colours are authored in the
|
||||
/// working space's encoding; ACES spaces are linear.)
|
||||
#[test]
|
||||
fn exr_holds_scene_linear_values_in_output_primaries() {
|
||||
let [_, _, band] = BANDS;
|
||||
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
|
||||
let cases = [
|
||||
// ACEScg in and out: nothing converts.
|
||||
(ColorSpace::AcesCg, ColorSpace::AcesCg, band),
|
||||
// ACEScg working space, Display P3 output: the primaries convert, the values stay linear.
|
||||
(ColorSpace::AcesCg, ColorSpace::DisplayP3, Conversion::new(ColorSpace::AcesCg, true, ColorSpace::DisplayP3, true).expect("matrix").apply(band)),
|
||||
// Linearized Rec. 2020 working space, sRGB output.
|
||||
(ColorSpace::Rec2020, ColorSpace::Srgb, Conversion::new(ColorSpace::Rec2020, false, ColorSpace::Srgb, true).expect("matrix").apply(band)),
|
||||
// Linearized sRGB in and out.
|
||||
(ColorSpace::Srgb, ColorSpace::Srgb, band.map(effectcraft_color::srgb_to_linear)),
|
||||
];
|
||||
for (ws, out, want) in cases {
|
||||
let (p, cid) = project(BitDepth::Bpc32, mode, Some(ws), true, Some(out));
|
||||
let what = format!("{mode:?} {ws:?} to {out:?}");
|
||||
assert_near(exr_centre(&p, cid, &format!("lin-{ws:?}-{out:?}-{mode:?}")), want.map(|v| v.max(0.0)), &what);
|
||||
}
|
||||
}
|
||||
let (p, cid) = project(BitDepth::Bpc32, HdrMode::ToneMap, Some(ColorSpace::AcesCg), true, Some(ColorSpace::AcesCg));
|
||||
assert_eq!(exr_centre(&p, cid, "lin-exact")[..3], band, "4.0 is exactly 4.0");
|
||||
}
|
||||
|
||||
/// Rec. 2100 PQ and HLG outputs (no HDR step there in any mode) are decoded with their own
|
||||
/// curves: linear light in Rec. 2020 primaries, 1.0 = reference white (203 cd/m²).
|
||||
#[test]
|
||||
fn exr_decodes_pq_and_hlg_outputs() {
|
||||
let [_, _, band] = BANDS;
|
||||
let want = Conversion::new(ColorSpace::AcesCg, true, ColorSpace::Rec2020, true).expect("matrix").apply(band).map(|v| v.max(0.0));
|
||||
for out in [ColorSpace::Rec2100Pq, ColorSpace::Rec2100Hlg] {
|
||||
for mode in MODES {
|
||||
let (p, cid) = project(BitDepth::Bpc32, mode, Some(ColorSpace::AcesCg), true, Some(out));
|
||||
let got = exr_centre(&p, cid, &format!("hdr-out-{out:?}-{mode:?}"));
|
||||
for i in 0..3 {
|
||||
assert!((got[i] - want[i]).abs() <= 1e-3 * want[i].max(1.0), "{out:?} {mode:?} channel {i}: got {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The EXR is decoded with the output space's curve, not always sRGB's: a Rec. 709 output (pure
|
||||
/// 2.4 power curve) and an unmanaged project at Working Gamma 2.4 give back the linear values.
|
||||
#[test]
|
||||
fn exr_decodes_with_the_output_curve() {
|
||||
let [_, _, band] = BANDS;
|
||||
// A linearized sRGB working space (same primaries as Rec. 709): the authored colour, decoded.
|
||||
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, Some(ColorSpace::Srgb), true, Some(ColorSpace::Rec709));
|
||||
assert_near(exr_centre(&p, cid, "curve-709"), band.map(effectcraft_color::srgb_to_linear), "Rec. 709 output");
|
||||
// Unmanaged at 2.4: the solid's colour is encoded with the 2.4 power curve.
|
||||
let (mut p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, None, false, None);
|
||||
p.settings.working_gamma = 2.4;
|
||||
assert_near(exr_centre(&p, cid, "curve-gamma24"), band.map(|v| v.powf(2.4)), "Working Gamma 2.4");
|
||||
// Working Gamma 2.2 keeps the sRGB curve.
|
||||
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, None, false, None);
|
||||
assert_near(exr_centre(&p, cid, "curve-gamma22"), band.map(effectcraft_color::srgb_to_linear), "Working Gamma 2.2");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod banding;
|
||||
mod exr_scene_linear;
|
||||
mod h264_gpu_yuv;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -95,8 +95,9 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec<u8> {
|
||||
fn h264_movie_bytes_are_pinned() {
|
||||
let data = export_bytes(OutputFormat::H264, "/out/pinned.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");
|
||||
// bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin. The encoder's
|
||||
// version string "0.8.0" -> "0.12.0" in that SEI adds one more byte (and one to the size fields).
|
||||
assert_eq!((data.len(), fnv1a(&data)), (104_342, 17_327_612_315_319_115_693), "H.264 MP4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::render_queue::{AlphaMode, AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, Progress, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
@@ -70,6 +70,7 @@ fn settings() -> RenderSettings {
|
||||
RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Exports the standard test comp and checks progress for all [`FRAMES`] frames.
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
run_frames(p, cid, footage, om, path, FRAMES)
|
||||
}
|
||||
@@ -251,6 +252,65 @@ fn prores_4444_with_alpha() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks stored alpha run codewords in exported MOVs across both 4444 profiles,
|
||||
/// alpha modes and motion-blur settings, without relying on permissive decoders.
|
||||
#[test]
|
||||
fn prores_alpha_final_run_is_present_in_exported_movies() {
|
||||
let dir = out_dir("prores-alpha-final-run");
|
||||
let png = dir.join("alpha.png");
|
||||
// One macroblock with 255 transparent pixels followed by one opaque pixel.
|
||||
// The last alpha difference is -1 (modulo 65536), requiring a singleton run.
|
||||
let mut pixels = image::RgbaImage::new(16, 16);
|
||||
pixels.put_pixel(15, 15, image::Rgba([255, 80, 20, 255]));
|
||||
pixels.save(&png).expect("synthetic alpha PNG");
|
||||
|
||||
for profile in [ProResProfile::P4444, ProResProfile::P4444Xq] {
|
||||
for alpha_mode in [AlphaMode::Straight, AlphaMode::Premultiplied] {
|
||||
for motion_blur in [false, true] {
|
||||
let mut project = Project::default();
|
||||
let rate = FrameRate::FPS_30;
|
||||
let mut comp = Comp::new(16, 16, rate, rate.tick_of(1));
|
||||
comp.enable_motion_blur = motion_blur;
|
||||
let footage = effectcraft_media::probe(&png).expect("probe synthetic PNG");
|
||||
let item = project.add_item("Alpha", Label::SeaFoam, None, ItemKind::Footage(footage));
|
||||
let mut layer = build::layer(&mut project, &comp, "Alpha", LayerSource::Footage { item }, (16, 16), None);
|
||||
layer.switches.motion_blur = motion_blur;
|
||||
comp.layers.push(layer);
|
||||
let comp = project.add_item("Alpha", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let output = OutputModule {
|
||||
channels: Channels::Rgba,
|
||||
prores_profile: profile,
|
||||
alpha_mode,
|
||||
audio: AudioOutput::Off,
|
||||
..OutputModule::for_format(OutputFormat::ProRes)
|
||||
};
|
||||
let path = dir.join(format!("{profile:?}-{alpha_mode:?}-blur-{motion_blur}.mov"));
|
||||
run_frames(&project, comp, &MediaPool::new(), &output, &path, 1);
|
||||
|
||||
let bytes = std::fs::read(&path).expect("exported MOV");
|
||||
let movie = filmcraft_isobmff::open(bytes.as_slice()).expect("demux exported MOV");
|
||||
let video = movie.tracks.iter().find(|track| track.kind == filmcraft_isobmff::TrackKind::Video).expect("video track");
|
||||
assert_eq!(video.samples.len(), 1);
|
||||
let sample = &video.samples[0];
|
||||
let frame = &bytes[sample.offset as usize..][..sample.size as usize];
|
||||
let (header, start, size) = filmcraft_prores::FrameHeader::parse(frame).expect("ProRes header");
|
||||
assert_eq!((header.width, header.height), (16, 16));
|
||||
let picture = &frame[start..size];
|
||||
let header = filmcraft_prores::PictureHeader::parse(picture).expect("picture header");
|
||||
assert_eq!(header.num_slices, 1);
|
||||
let slice = &picture[header.header_size + 2..];
|
||||
let color_bytes: usize = slice[2..8].as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b) as usize).sum();
|
||||
let alpha = &slice[(slice[0] >> 3) as usize + color_bytes..];
|
||||
// RDD 36:2022 Tables 12–14: +1, run 255, -1, run 1, alignment.
|
||||
// The old encoder stopped after -1, giving [00 00 fe 01]. Its
|
||||
// own decoder and FFmpeg accept that truncated payload, so a
|
||||
// round-trip alone cannot enforce the native decoder's syntax.
|
||||
assert_eq!(alpha, [0x00, 0x00, 0xfe, 0x01, 0x80], "{profile:?}, {alpha_mode:?}, blur={motion_blur}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_still_dir(dir: &Path, ext: &str, tol: f32, alpha: bool) {
|
||||
for k in 0..FRAMES {
|
||||
let path = sequence_path(&dir.join(format!("f_[#####].{ext}")).to_string_lossy(), k as i64);
|
||||
|
||||
@@ -1295,3 +1295,158 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
|
||||
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// One runtime serves every expression on a thread, and user code runs as a script at global
|
||||
// scope, so `linear = …` or a top-level `var clamp` would otherwise rebind a prelude name for
|
||||
// every later expression, and for every later project in the session (#2133). The block above
|
||||
// closes the `__` globals, the model constructors and the prelude's own prototypes; what is left
|
||||
// is the rest of the global surface: the prelude's own helpers (`linear`, `clamp`, `wiggle`), the
|
||||
// JavaScript built-ins that an expression and the prelude both call, and the prototypes those
|
||||
// hang off. A non-writable, non-configurable binding makes such a write a silent no-op in sloppy
|
||||
// mode, and `var` leaves an existing global property alone. A name the user introduces is not
|
||||
// frozen, so an expression that declares its own functions still works.
|
||||
(function () {
|
||||
var g = globalThis;
|
||||
// Every intrinsic this block needs, read once before the first prototype is rewritten. The
|
||||
// block turns `Array.prototype.concat` and `forEach` into accessors as it goes, so reaching for
|
||||
// them through a prototype part-way down the list would be reaching for something it has just
|
||||
// changed under itself: the loops below index arrays directly for the same reason.
|
||||
var describe = Object.getOwnPropertyDescriptor, define = Object.defineProperty;
|
||||
var getNames = Object.getOwnPropertyNames, getSyms = Object.getOwnPropertySymbols;
|
||||
var freeze = Object.freeze, isFrozen = Object.isFrozen, isExtensible = Object.isExtensible;
|
||||
var protoOf = Object.getPrototypeOf;
|
||||
|
||||
var lock = function (n) {
|
||||
var d = describe(g, n);
|
||||
if (!d || !d.writable) return;
|
||||
define(g, n, { value: d.value, writable: false, enumerable: d.enumerable, configurable: false });
|
||||
};
|
||||
|
||||
// `name` and `message` on an error's prototype are the two names the repair below has to leave
|
||||
// alone. The host renders a thrown error by reading both straight off the object, with no
|
||||
// context to call a getter with, and it has no way to run one safely either: the time budget is
|
||||
// checked between polls of the evaluation, and formatting happens after that returns, so a
|
||||
// getter of the expression's own would run outside it — bounded by the loop and recursion limits
|
||||
// at some hundreds of millions of iterations, which is a hang. Left as data properties they are
|
||||
// frozen rather than repaired, so `e.name = 'MyError'` on an expression's own error is dropped
|
||||
// in silence. That is the cost; an unbounded getter in the failure path is not worth paying to
|
||||
// avoid it.
|
||||
var HOST_READ = ['name', 'message'];
|
||||
var hostRead = [];
|
||||
var errorNames = ['Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError',
|
||||
'URIError', 'AggregateError'];
|
||||
for (var e = 0; e < errorNames.length; e++) {
|
||||
var C = g[errorNames[e]];
|
||||
if (typeof C === 'function') {
|
||||
var cd = describe(C, 'prototype');
|
||||
if (cd && 'value' in cd) hostRead.push(cd.value);
|
||||
}
|
||||
}
|
||||
var readByHost = function (p, n) {
|
||||
for (var i = 0; i < HOST_READ.length; i++) {
|
||||
if (n !== HOST_READ[i]) continue;
|
||||
for (var j = 0; j < hostRead.length; j++) if (hostRead[j] === p) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Freezing a prototype costs an expression the right to shadow an inherited name on its own
|
||||
// object: once the inherited data property is non-writable, `o.toString = …` is dropped in
|
||||
// sloppy mode rather than creating an own property — the "override mistake". That is ordinary
|
||||
// expression code: `counts[w]` with `w` of "constructor" or "valueOf" uses an object as a
|
||||
// dictionary, and `a.map = …` gives one array its own version of a method. So every name that
|
||||
// was shadowable before the freeze becomes an accessor whose setter writes an own property on
|
||||
// the instance, which leaves the shadowing that worked before this block working after it.
|
||||
var tolerate = function (p, n) {
|
||||
var d = describe(p, n);
|
||||
// An accessor has no value to stand in for, and a name already non-writable or
|
||||
// non-configurable could not be shadowed before this ran either: no regression to repair.
|
||||
if (!d || !d.configurable || !('value' in d) || !d.writable) return;
|
||||
var v = d.value;
|
||||
define(p, n, {
|
||||
get: function () { return v; },
|
||||
set: function (nv) {
|
||||
// This setter stands in for the data property it replaced, so it answers a write the way
|
||||
// that property would: an accessor is handed the receiver and nothing else, while a data
|
||||
// property has the receiver's own property consulted first
|
||||
// (OrdinarySetWithOwnDescriptor). Doing that here keeps the repair invisible — an own
|
||||
// writable property is updated in place and keeps its other attributes, an own
|
||||
// non-writable one drops the write rather than being overwritten, and a target that
|
||||
// cannot take a new property is left alone instead of being handed to `defineProperty`,
|
||||
// which would throw. That last case covers a frozen prototype, an object the expression
|
||||
// froze itself, and `null` or `undefined`.
|
||||
if (this === p || this === null || this === undefined) return;
|
||||
var own = describe(this, n);
|
||||
if (own) {
|
||||
if (own.writable) define(this, n, { value: nv });
|
||||
return;
|
||||
}
|
||||
if (!isExtensible(this)) return;
|
||||
define(this, n, { value: nv, writable: true, enumerable: true, configurable: true });
|
||||
},
|
||||
enumerable: d.enumerable,
|
||||
configurable: false,
|
||||
});
|
||||
};
|
||||
|
||||
var freezeProto = function (p) {
|
||||
if (p === null || (typeof p !== 'object' && typeof p !== 'function') || isFrozen(p)) return;
|
||||
var names = getNames(p);
|
||||
for (var i = 0; i < names.length; i++) if (!readByHost(p, names[i])) tolerate(p, names[i]);
|
||||
// Symbol keys go the same way: `a[Symbol.iterator] = …` on an expression's own array is an
|
||||
// ordinary way to make something iterable, and left frozen it is dropped in silence while
|
||||
// `for…of` quietly walks the array's own elements instead.
|
||||
var syms = getSyms(p);
|
||||
for (var j = 0; j < syms.length; j++) tolerate(p, syms[j]);
|
||||
freeze(p);
|
||||
};
|
||||
|
||||
// Freezing a function also makes its own `prototype` property non-writable, so `Comp.prototype =
|
||||
// {}` is a no-op; freezing that prototype object closes the methods hanging off it.
|
||||
var shut = function (o) {
|
||||
var d = describe(o, 'prototype');
|
||||
if (d && 'value' in d) freezeProto(d.value);
|
||||
freeze(o);
|
||||
};
|
||||
|
||||
var globals = getNames(g);
|
||||
for (var k = 0; k < globals.length; k++) {
|
||||
var n = globals[k];
|
||||
var d = describe(g, n);
|
||||
if (!d || !('value' in d) || typeof d.value !== 'function') continue;
|
||||
// The function object as well as the binding: the prelude calls `Array.isArray` and
|
||||
// `Object.setPrototypeOf` while it answers an expression, so a replacement for one of those
|
||||
// would not merely shadow a name, it would change what a later expression computes.
|
||||
shut(d.value);
|
||||
lock(n);
|
||||
}
|
||||
// `Math.sin = …` goes the same way, and the prelude leans on both of these itself. They are the
|
||||
// `prototype` of no function, so the loop above never reaches them, and they are the prototype
|
||||
// of no object either: nothing inherits `sin` or `parse`, so there is no shadowing for the
|
||||
// accessor repair to keep, and a plain freeze closes them. That matters on the hot path, where
|
||||
// a repaired name costs an accessor call on every read and a per-frame expression reads
|
||||
// `Math.*` more than anything else. The bindings are locked as well, so `Math = {}` cannot take
|
||||
// them away from the next expression either.
|
||||
freeze(Math);
|
||||
freeze(JSON);
|
||||
lock('Math');
|
||||
lock('JSON');
|
||||
// The iterator intrinsics are the `prototype` of no global function either, and `for…of`,
|
||||
// destructuring and spread all run through them: replacing `next` on the array iterator left
|
||||
// every later expression on the thread unable to iterate at all.
|
||||
var walk = function (o) {
|
||||
for (var p = o; p !== null && p !== undefined; p = protoOf(p)) freezeProto(p);
|
||||
};
|
||||
var probes = [function () { return [][Symbol.iterator](); },
|
||||
function () { return ''[Symbol.iterator](); },
|
||||
function () { return new Map()[Symbol.iterator](); },
|
||||
function () { return new Set()[Symbol.iterator](); },
|
||||
function () { return (function* () {})(); },
|
||||
function () { return 'a'.matchAll(/a/g); },
|
||||
function () { return Int8Array.prototype; }];
|
||||
for (var m = 0; m < probes.length; m++) {
|
||||
// Each one is a probe for an intrinsic this engine may not have at all.
|
||||
try { walk(protoOf(probes[m]())); } catch (e) { /* not present */ }
|
||||
}
|
||||
})();
|
||||
@@ -193,6 +193,315 @@ fn functions_and_let_are_reusable_across_evaluations() {
|
||||
assert_close!(f.num(e, 2.0), 9.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expression_cannot_rebind_a_prelude_name_for_the_next_one() {
|
||||
let f = fx();
|
||||
// #2133: one runtime serves every expression on a thread and user code runs at global scope, so
|
||||
// a write to a prelude name used to outlive the expression that made it and poison every later
|
||||
// one — in this session's other projects too.
|
||||
assert_close!(f.num("linear = function () { return 777 };\n50", 0.0), 50.0);
|
||||
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||||
assert_close!(f.num("var clamp = 5;\n10", 0.0), 10.0);
|
||||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(f.num("Math.sin = function () { return 9 };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||||
assert_close!(f.num("Object.prototype.leak = 9;\n2", 0.0), 2.0);
|
||||
assert_close!(f.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
|
||||
// The runtime is one thread-local singleton, so a second project on this thread meets the same
|
||||
// objects: the isolation has to hold between projects, not only between the evaluations of one.
|
||||
let g = fx();
|
||||
assert_close!(g.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||||
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||||
assert_close!(g.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The four ways a script can reach a global binding, against a prelude *function* rather than one
|
||||
/// of the unqualified accessors: assignment, `var`, `delete` and `defineProperty`. The lock is
|
||||
/// `writable: false, configurable: false`, and it is the second half of that pair that answers the
|
||||
/// last two — a `delete` of a non-configurable property is false, and a `defineProperty` over one
|
||||
/// is a `TypeError`. Asked for by a reviewer on #2176.
|
||||
#[test]
|
||||
fn a_prelude_function_survives_every_way_a_script_can_reach_its_binding() {
|
||||
let f = fx();
|
||||
// Assignment and `var`: silent, and the next expression still has the original.
|
||||
assert_close!(f.num("clamp = function () { return 777 };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("var clamp = 5;\n2", 0.0), 2.0);
|
||||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
// `delete` of a non-configurable binding answers false in sloppy mode rather than throwing.
|
||||
assert_close!(f.num("delete clamp ? 1 : 0", 0.0), 0.0);
|
||||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(f.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
|
||||
// `defineProperty` over one is a `TypeError`, so this is the one route the user does see. The
|
||||
// expression fails; what it was reaching for is unchanged for the next one.
|
||||
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { value: 5 })").contains("TypeError"));
|
||||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
// Reconfiguring it to writable is refused the same way, so the lock cannot be reopened.
|
||||
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { writable: true })").contains("TypeError"));
|
||||
assert_close!(f.num("clamp = 7;\n3", 0.0), 3.0);
|
||||
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
// A second project on the same thread-local runtime meets the same bindings.
|
||||
let g = fx();
|
||||
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(g.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// Which mode a write is silent in decides what the user sees, and the answer is that an expression
|
||||
/// cannot reach strict mode at all: `Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`,
|
||||
/// so a `"use strict"` the user writes is an expression statement inside a block rather than a
|
||||
/// directive prologue, and has no effect. A failed write is therefore always the silent kind.
|
||||
/// Asked for by a reviewer on #2176.
|
||||
#[test]
|
||||
fn a_use_strict_in_an_expression_is_inert_so_a_refused_write_stays_silent() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("\"use strict\";\nlinear = function () { return 777 };\n50", 0.0), 50.0);
|
||||
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
|
||||
// The directive is inert rather than honoured: in strict mode an assignment to an undeclared
|
||||
// name is a ReferenceError, and here it quietly creates a global instead.
|
||||
assert_close!(f.num("\"use strict\";\nundeclared_global = 5;\nundeclared_global", 0.0), 5.0);
|
||||
// Which is also why the refused write above cannot be made to report itself by the user: the
|
||||
// only mode an expression runs in is the one where it is dropped.
|
||||
assert_close!(f.num("\"use strict\";\ntypeof linear === 'function' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The same leak, reached through the objects instead of the names: a prelude constructor keeps its
|
||||
/// methods on its `prototype`, the prelude itself calls `Array.isArray` and `Object.setPrototypeOf`
|
||||
/// while it answers an expression, and `thisProject` is reachable surface that holds no function at
|
||||
/// all (#2133).
|
||||
#[test]
|
||||
fn an_expression_cannot_tamper_with_a_prelude_object_for_the_next_one() {
|
||||
let f = fx();
|
||||
// An unqualified name is an accessor, so it takes a `defineProperty` or a `delete` rather than
|
||||
// an assignment; the setter an assignment goes through writes per-evaluation state.
|
||||
assert_close!(f.num("try { Object.defineProperty(globalThis, 'width', { value: 777 }) } catch (e) {}\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("delete width;\n2", 0.0), 2.0);
|
||||
assert_close!(f.num("width", 0.0), 20.0);
|
||||
assert_close!(f.num("Comp.prototype = {};\n3", 0.0), 3.0);
|
||||
assert_close!(f.num("Comp.prototype.leak = 9;\n4", 0.0), 4.0);
|
||||
assert_close!(f.num("thisComp.width", 0.0), 200.0);
|
||||
// `add` reads its arguments through `Array.isArray`, and reaching an effect builds a group with
|
||||
// `Object.setPrototypeOf`, so a replacement for either would change the answers below.
|
||||
assert_close!(f.num("Array.isArray = function () { return false };\n5", 0.0), 5.0);
|
||||
assert_close!(f.num("Object.setPrototypeOf = function (o) { return o };\n6", 0.0), 6.0);
|
||||
assert_close!(f.num("add([1, 2], [3, 4])[0]", 0.0), 4.0);
|
||||
assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0);
|
||||
assert_close!(f.num("thisProject = 5;\nthisProject.leak = 9;\n7", 0.0), 7.0);
|
||||
assert_close!(f.num("typeof thisProject === 'object' && thisProject.leak === undefined ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// `Math` and `JSON` are namespaces rather than prototypes: nothing inherits from them, so the
|
||||
/// prelude freezes them plainly instead of turning every method into a shadowable accessor, which
|
||||
/// would put a getter call on every `Math.sin` of a per-frame expression for a shadowing no object
|
||||
/// can do. They still cannot be rewritten, through a member or through the binding (#2176).
|
||||
#[test]
|
||||
fn math_and_json_are_frozen_and_cannot_be_replaced_for_the_next_expression() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("Object.isFrozen(Math) && Object.isFrozen(JSON) ? 1 : 0", 0.0), 1.0);
|
||||
// Plain data properties, not the accessor repair: the hot path reads the native method directly.
|
||||
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(Math, 'sin') ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(JSON, 'stringify') ? 1 : 0", 0.0), 1.0);
|
||||
// A member write is dropped in silence, and the next expression still has the original.
|
||||
assert_close!(f.num("Math.sin = function () { return 9 }; Math.max = function () { return -1 };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("Math.sin(Math.PI / 2) * 100 + Math.max(3, 7)", 0.0), 107.0);
|
||||
assert_close!(f.num("JSON.stringify = function () { return 'x' }; JSON.parse = null;\n2", 0.0), 2.0);
|
||||
assert_close!(f.num("JSON.stringify(JSON.parse('[1,2]')) === '[1,2]' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("Math.leak = 5; JSON.leak = 6;\n3", 0.0), 3.0);
|
||||
assert_close!(f.num("Math.leak === undefined && JSON.leak === undefined ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("delete Math.floor ? 1 : 0", 0.0), 0.0);
|
||||
assert!(f.err("Object.defineProperty(Math, 'floor', { value: 5 })").contains("TypeError"));
|
||||
// The bindings are locked too: assignment, `var` and `delete` leave both in place.
|
||||
assert_close!(f.num("Math = { sin: function () { return 9 } }; JSON = null;\n4", 0.0), 4.0);
|
||||
assert_close!(f.num("var Math = 5; var JSON = 6;\n5", 0.0), 5.0);
|
||||
assert_close!(f.num("delete Math || delete JSON ? 1 : 0", 0.0), 0.0);
|
||||
assert_close!(f.num("Math.floor(2.7) + JSON.parse('3')", 0.0), 5.0);
|
||||
// A second project on the same thread-local runtime meets the same objects.
|
||||
let g = fx();
|
||||
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
|
||||
assert_close!(g.num("JSON.stringify([1]) === '[1]' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// Closing the prototypes must not cost an expression its own objects: once an inherited data
|
||||
/// property is non-writable, an assignment meaning to shadow it on an instance is dropped in sloppy
|
||||
/// mode rather than creating an own property, and using an object as a dictionary runs into exactly
|
||||
/// those names (#2133).
|
||||
#[test]
|
||||
fn an_expression_can_shadow_an_inherited_name_on_its_own_object() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var o = {}; o.toString = function () { return 'x' }; o.toString() === 'x' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("var d = {}; d.constructor = 3; d.constructor", 0.0), 3.0);
|
||||
assert_close!(f.num("var c = {}; c['valueOf'] = 2; c['valueOf']", 0.0), 2.0);
|
||||
// The shared prototype itself still cannot be written through, which is the point of freezing.
|
||||
assert_close!(f.num("Object.prototype.toString = function () { return 'z' };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("({}).toString() === 'z' ? 0 : 1", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The names an expression shadows are not only `Object.prototype`'s: giving one array its own
|
||||
/// `map` or one function its own `call` is ordinary JavaScript, and a prototype frozen without the
|
||||
/// repair takes it away. The two halves fail differently, which is why both are checked here —
|
||||
/// `map`, `forEach` and `filter` throw, while `push` and `join` silently run the native method and
|
||||
/// return its answer, so an expression using them keeps working and quietly computes something
|
||||
/// else (#2133).
|
||||
#[test]
|
||||
fn an_expression_can_shadow_an_inherited_method_on_its_own_array_or_function() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var a = []; a.map = function () { return 42 }; a.map()", 0.0), 42.0);
|
||||
assert_close!(f.num("var a = [1, 2]; a.forEach = function () { return 5 }; a.forEach()", 0.0), 5.0);
|
||||
assert_close!(f.num("var a = [1, 2]; a.filter = function () { return 6 }; a.filter()", 0.0), 6.0);
|
||||
// `push` returned the native length and `join` the native string before the repair.
|
||||
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2)", 0.0), 99.0);
|
||||
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2); a.length", 0.0), 1.0);
|
||||
assert_close!(f.num("var a = [1]; a.join = function () { return 'J' }; a.join() === 'J' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("var fn = function () {}; fn.call = function () { return 7 }; fn.call()", 0.0), 7.0);
|
||||
assert_close!(f.num("var fn = function () {}; fn.apply = function () { return 8 }; fn.apply()", 0.0), 8.0);
|
||||
// A symbol key on a prototype the loop reaches goes the same way.
|
||||
assert_close!(f.num("var a = [1]; a[Symbol.toPrimitive] = function () { return 11 }; +a", 0.0), 11.0);
|
||||
// Reading an unshadowed method still answers with the native one, through the repair.
|
||||
assert_close!(f.num("[1, 2, 3].map(function (x) { return x * 2 })[2]", 0.0), 6.0);
|
||||
assert_close!(f.num("[1, 2, 3].filter(function (x) { return x > 1 }).length", 0.0), 2.0);
|
||||
assert_close!(f.num("[3, 1, 2].sort()[0]", 0.0), 1.0);
|
||||
assert_close!(f.num("[1, 2].concat([3]).length", 0.0), 3.0);
|
||||
// The shared prototype itself still cannot be written through, which is the point of freezing.
|
||||
assert_close!(f.num("Array.prototype.map = function () { return 0 };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("[1].map(function (x) { return x + 1 })[0]", 0.0), 2.0);
|
||||
assert_close!(f.num("Function.prototype.call = function () { return 0 };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("(function () { return 4 }).call(null)", 0.0), 4.0);
|
||||
}
|
||||
|
||||
/// `name` and `message` on an error's prototype are the one exception to the repair, because the
|
||||
/// host reads both off a thrown object to render it and has no context to call a getter with, nor
|
||||
/// anywhere safe to call one: formatting happens after the evaluation's time budget stops being
|
||||
/// checked. What matters is that the message still names the error and the line, and that the
|
||||
/// assignment the exception costs is dropped in silence rather than failing the expression (#2133).
|
||||
#[test]
|
||||
fn an_error_still_renders_with_its_name_and_the_users_line() {
|
||||
let f = fx();
|
||||
// The write the exception costs: a no-op, not a throw, and the next statement still runs.
|
||||
assert_close!(f.num("var e = new Error('x'); e.name = 'MyError'; 1", 0.0), 1.0);
|
||||
assert_close!(f.num("var e = new Error('x'); e.name === 'Error' ? 1 : 0", 0.0), 1.0);
|
||||
// An own `message` is the instance's, so it is untouched by any of this.
|
||||
assert_close!(f.num("new Error('x').message === 'x' ? 1 : 0", 0.0), 1.0);
|
||||
// The prototype itself still cannot be rebound for the next expression, which is the point.
|
||||
assert_close!(f.num("Error.prototype.name = 'Hacked';\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("new Error('x').name === 'Error' ? 1 : 0", 0.0), 1.0);
|
||||
// A getter the expression installs on its own error is reached through its own property, so it
|
||||
// never goes near the host's read of the prototype.
|
||||
assert_close!(f.num("var e = new Error('x'); Object.defineProperty(e, 'name', { get: function () { return 'Own' } }); e.name === 'Own' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The repair must not turn a dropped assignment into a failed expression: in sloppy mode a write
|
||||
/// to a frozen target is ignored, and the setter runs for every receiver that inherits the name,
|
||||
/// including the other frozen prototypes and an object the expression froze itself.
|
||||
#[test]
|
||||
fn assigning_a_repaired_name_on_a_frozen_object_is_dropped_not_an_error() {
|
||||
let f = fx();
|
||||
// `hasOwnProperty` is `Object.prototype`'s, so this runs the repaired setter with a frozen
|
||||
// `Array.prototype` as the receiver.
|
||||
assert_close!(f.num("Array.prototype.hasOwnProperty = function () { return true };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("[].hasOwnProperty('length') ? 1 : 0", 0.0), 1.0);
|
||||
// The prelude freezes these, and each inherits the repaired names.
|
||||
assert_close!(f.num("Math.toString = function () { return 'm' };\n2", 0.0), 2.0);
|
||||
assert_close!(f.num("JSON.valueOf = 3;\n3", 0.0), 3.0);
|
||||
assert_close!(f.num("thisProject.toLocaleString = 4;\n4", 0.0), 4.0);
|
||||
// An object the expression froze itself behaves as it does outside the prelude.
|
||||
assert_close!(f.num("var o = {}; Object.freeze(o); o.toString = function () { return 'x' };\n5", 0.0), 5.0);
|
||||
assert_close!(f.num("var s = {}; Object.seal(s); s.constructor = 6;\n6", 0.0), 6.0);
|
||||
// Still a no-op where it has to be, and still working where it has to work.
|
||||
assert_close!(f.num("({}).toString() === '[object Object]' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("var k = {}; k.toString = function () { return 'y' }; k.toString() === 'y' ? 1 : 0", 0.0), 1.0);
|
||||
// A primitive target boxes into a throwaway wrapper, and `null` is not extensible, which is why
|
||||
// the receiver check alone is enough.
|
||||
assert_close!(f.num("'abc'.toString = function () { return 'q' };\n7", 0.0), 7.0);
|
||||
assert_close!(f.num("(5).constructor = 8;\n8", 0.0), 8.0);
|
||||
assert_close!(f.num("Object.isExtensible(null) === false && Object.isExtensible(undefined) === false ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
/// The repaired name is an accessor standing in for a data property, and an accessor is handed the
|
||||
/// receiver while a data property has the receiver's own property consulted first, so the setter
|
||||
/// has to do that itself or the substitution shows through. An ordinary assignment finds the own
|
||||
/// property and never reaches the setter, so these call it with the receiver directly, which is
|
||||
/// what `Reflect.set(Object.prototype, name, v, o)` would do; `Reflect` is not available to
|
||||
/// expressions. Every expected value is what that `Reflect.set` answers in node, where the name
|
||||
/// is still the data property this one stands in for.
|
||||
#[test]
|
||||
fn the_repair_answers_a_write_as_the_data_property_it_replaced_would() {
|
||||
let f = fx();
|
||||
let set = "var put = Object.getOwnPropertyDescriptor(Object.prototype, 'toString').set;";
|
||||
// An own writable property is updated in place, even when the receiver takes no new ones.
|
||||
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; Object.preventExtensions(o); put.call(o, 2); o.toString"), 0.0), 2.0);
|
||||
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; put.call(o, 2); o.toString"), 0.0), 2.0);
|
||||
// ... and keeps the attributes it had, rather than being redefined as a fresh enumerable one.
|
||||
assert_close!(
|
||||
f.num(
|
||||
&format!(
|
||||
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: true, enumerable: false, configurable: true }});
|
||||
put.call(o, 2); Object.getOwnPropertyDescriptor(o, 'toString').enumerable ? 1 : 0"
|
||||
),
|
||||
0.0
|
||||
),
|
||||
0.0
|
||||
);
|
||||
// An own non-writable property drops the write instead of being overwritten.
|
||||
assert_close!(
|
||||
f.num(
|
||||
&format!(
|
||||
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: false, enumerable: false, configurable: true }});
|
||||
put.call(o, 2); o.toString"
|
||||
),
|
||||
0.0
|
||||
),
|
||||
1.0
|
||||
);
|
||||
// Writing through the prototype itself changes nothing for anyone.
|
||||
assert_close!(f.num(&format!("{set} put.call(Object.prototype, 2); ({{}}).toString === undefined ? 0 : 1"), 0.0), 1.0);
|
||||
// An ordinary assignment finds the own property first and never reaches the setter at all.
|
||||
assert_close!(f.num("var o = { toString: 1 }; o.toString = 2; o.toString", 0.0), 2.0);
|
||||
}
|
||||
|
||||
/// `for…of`, destructuring and spread all go through the iterator intrinsics, and those are the
|
||||
/// `prototype` of no global function, so freezing what the globals reach never touched them (#2133).
|
||||
#[test]
|
||||
fn an_expression_cannot_poison_iteration_for_the_next_one() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("Object.getPrototypeOf([][Symbol.iterator]()).next = function () { return { done: true } };\n1", 0.0), 1.0);
|
||||
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
|
||||
assert_close!(f.num("var [a, b] = [4, 5]; a + b", 0.0), 9.0);
|
||||
assert_close!(f.num("Math.max(...[1, 7, 3])", 0.0), 7.0);
|
||||
}
|
||||
|
||||
/// Shadowing an inherited name on an instance has to keep working for symbol keys too: making an
|
||||
/// object iterable by assigning `Symbol.iterator` is ordinary JavaScript, and a frozen
|
||||
/// `Array.prototype` drops that write in silence, so `for…of` walks the array's own elements and
|
||||
/// quietly returns a different number rather than failing (#2133).
|
||||
#[test]
|
||||
fn an_expression_can_give_its_own_array_an_iterator() {
|
||||
let f = fx();
|
||||
assert_close!(
|
||||
f.num(
|
||||
"var a = [1, 2];
|
||||
a[Symbol.iterator] = function () { var d = false; return { next: function () { if (d) return { done: true }; d = true; return { value: 9, done: false } } } };
|
||||
var s = 0; for (var x of a) s += x; s",
|
||||
0.0
|
||||
),
|
||||
9.0
|
||||
);
|
||||
assert_close!(f.num("var a = [1, 2]; a[Symbol.iterator] = 1; a[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
|
||||
// `Object.prototype` carries no symbol keys, so a plain object was never affected.
|
||||
assert_close!(f.num("var o = {}; o[Symbol.iterator] = 1; o[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
|
||||
// An inherited name that is already non-writable is left alone, as it is outside the prelude:
|
||||
// `Date.prototype[Symbol.toPrimitive]` drops the write on `main` and in node as well.
|
||||
assert_close!(f.num("var d = new Date(0); d[Symbol.toPrimitive] = 1; d[Symbol.toPrimitive] === 1 ? 1 : 0", 0.0), 0.0);
|
||||
// Repairing the key must not reopen it: the shared prototype still cannot be written through.
|
||||
assert_close!(
|
||||
f.num(
|
||||
"Array.prototype[Symbol.iterator] = function () { return { next: function () { return { done: true } } } };
|
||||
1",
|
||||
0.0
|
||||
),
|
||||
1.0
|
||||
);
|
||||
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_are_ignored() {
|
||||
let f = fx();
|
||||
@@ -1672,3 +1981,47 @@ fn an_expression_cant_change_the_prelude_for_the_next() {
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
|
||||
/// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not
|
||||
/// an assertion. `cargo test -p effectcraft-expr --release --lib bench_evaluation -- --ignored
|
||||
/// --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing measurement, not a test"]
|
||||
fn bench_evaluation() {
|
||||
let f = fx();
|
||||
// Each case runs on a property whose dimension its answer fits: a scalar handed to `position`
|
||||
// is rejected after the script has already run, so the case would be timing an error path.
|
||||
// Ordinary expressions first, then three that read an inherited name in a loop: the repair
|
||||
// turns each of those reads into an accessor call, and `Array.prototype.map` is the one an
|
||||
// expression is most likely to reach for in quantity.
|
||||
let cases: [(&str, &str, &str, u32); 10] = [
|
||||
("array maths", POS, "value + [10, 0]", 2500),
|
||||
("scalar maths", ROT, "time * 100 + 5", 3000),
|
||||
("prelude call", ROT, "linear(time, 0, 1, 0, 100)", 2500),
|
||||
("cross-layer read", ROT, "thisComp.layer(\"B\").transform.position[0]", 200),
|
||||
("effect read", ROT, "effect(\"Slider Control\")(\"Slider\")", 150),
|
||||
("wiggle", POS, "wiggle(2, 10)", 800),
|
||||
("dictionary", ROT, "var c = {}; var k = 'constructor'; c[k] = 1; c[k] + value", 2500),
|
||||
("toString x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.toString) n++; } n", 250),
|
||||
("hasOwnProperty x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.hasOwnProperty) n++; } n", 250),
|
||||
("array method x100", ROT, "var a = [1]; var n = 0; for (var i = 0; i < 100; i++) { if (a.map) n++; } n", 250),
|
||||
];
|
||||
for (label, path, text, iters) in cases {
|
||||
let mut best = u128::MAX;
|
||||
// Three passes, keeping the fastest: a slower pass is scheduling noise, not the prelude.
|
||||
for _ in 0..3 {
|
||||
// The warm-up checks as it goes, at every time the measured loop uses: that loop drops
|
||||
// its answer by design, so a case that had started failing would be timed on its error
|
||||
// path and still print a number.
|
||||
for i in 0..50 {
|
||||
assert!(f.ev(f.a, path, text, f64::from(i % 10) * 0.1).is_ok(), "{label}");
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
for i in 0..iters {
|
||||
let _ = f.ev(f.a, path, text, f64::from(i % 10) * 0.1);
|
||||
}
|
||||
best = best.min(start.elapsed().as_nanos() / u128::from(iters));
|
||||
}
|
||||
println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})");
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,8 @@ fn composite_mode(m: u32) -> BlendMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-pixel effects of `fp2_point` (Cineon, HDR Compander and Highlight Compression, CC
|
||||
/// Overbrights, CC Composite, Color Link): their parameters packed for the kernel, or `None`.
|
||||
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let mut p = Params::default();
|
||||
@@ -238,11 +240,12 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
"ec.utility.hdrcompression" => {
|
||||
let t = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
if t <= 0.0 {
|
||||
// A NaN Amount keeps the layer, as on the CPU.
|
||||
if t <= 0.0 || !t.is_finite() {
|
||||
return Some(b);
|
||||
}
|
||||
p.u[0][0] = 2;
|
||||
p.f[0][0] = 1.0 - 0.5 * t;
|
||||
p.f[0][0] = t;
|
||||
}
|
||||
"ec.utility.ccoverbrights" => {
|
||||
p.u[0] = [3, pr.e("channel"), 0, 0];
|
||||
|
||||
@@ -871,6 +871,17 @@ fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
|
||||
fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let (w, h) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||||
// Grow to hold the whole quad, like the CPU `corner_pin`.
|
||||
if !ctx.adjustment {
|
||||
let pts = ["ul", "ur", "lr", "ll"].map(|k| {
|
||||
let p = b.to_px(ctx.params.v2(k));
|
||||
[p.0, p.1]
|
||||
});
|
||||
let (lo, hi) = effectcraft_effects::quad_bounds(&pts);
|
||||
if let Some(sides) = effectcraft_effects::grow_sides(lo, hi, b.img.width, b.img.height) {
|
||||
b.pad_sides(e, sides)?;
|
||||
}
|
||||
}
|
||||
let q = ["ul", "ur", "lr", "ll"].map(|k| {
|
||||
let p = b.to_px(ctx.params.v2(k));
|
||||
vec2(p.0, p.1)
|
||||
|
||||
@@ -336,17 +336,21 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
o = vec4<f32>(r * px.w, px.w);
|
||||
}
|
||||
}
|
||||
// f[0].x = knee.
|
||||
// utility::hdr_compress_pixel; f[0].x = blend t (Amount / 100).
|
||||
case 2u: {
|
||||
if (px.w > 0.0) {
|
||||
let c = px.xyz / px.w;
|
||||
let m = max(max(c.x, c.y), c.z);
|
||||
let knee = P.f[0].x;
|
||||
if (m > knee) {
|
||||
let w = 1.0 - knee;
|
||||
let k = (knee + w * (m - knee) / ((m - knee) + w)) / m;
|
||||
o = vec4<f32>(c * k * px.w, px.w);
|
||||
}
|
||||
let t = P.f[0].x;
|
||||
let y = max(0.30 * c.x + 0.59 * c.y + 0.11 * c.z, 0.0);
|
||||
let comp = min(c * (1.0 / (1.0 + y)), vec3<f32>(1.0));
|
||||
let g = 0.4;
|
||||
let mixed = vec3<f32>(
|
||||
(1.0 - t) * fp2_spow(c.x, g) + t * fp2_spow(comp.x, g),
|
||||
(1.0 - t) * fp2_spow(c.y, g) + t * fp2_spow(comp.y, g),
|
||||
(1.0 - t) * fp2_spow(c.z, g) + t * fp2_spow(comp.z, g),
|
||||
);
|
||||
let r = vec3<f32>(fp2_spow(mixed.x, 1.0 / g), fp2_spow(mixed.y, 1.0 / g), fp2_spow(mixed.z, 1.0 / g));
|
||||
o = vec4<f32>(r * px.w, px.w);
|
||||
}
|
||||
}
|
||||
// u[0].y = channel; f[0] = highlight colour.
|
||||
|
||||
@@ -1460,6 +1460,38 @@ fn owned_devices_favour_memory_usage() {
|
||||
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
|
||||
}
|
||||
|
||||
/// `RenderOpts::skip_hdr_step` (float outputs) skips Project Settings ▸ HDR on the GPU as on the
|
||||
/// CPU: both compositors agree, and the frame is the Clip frame with its over-range values.
|
||||
#[test]
|
||||
fn skipping_the_hdr_step_on_the_gpu_matches_clip() {
|
||||
use effectcraft_project::HdrMode;
|
||||
let Some(g) = gpu() else {
|
||||
// Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead).
|
||||
eprintln!("skipping_the_hdr_step_on_the_gpu_matches_clip: skipped: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
|
||||
let mut s = blend_scene(BitDepth::Bpc32, BlendMode::Normal);
|
||||
let mut bright = s.solid([3.0, 1.5, 0.5], 30, 20);
|
||||
bright.blend_mode = BlendMode::Add;
|
||||
s.push(bright);
|
||||
s.p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
s.p.settings.linearize = true;
|
||||
s.p.settings.hdr = mode;
|
||||
let scene = RenderOpts { skip_hdr_step: true, ..opts() };
|
||||
check(&format!("no HDR step {mode:?}"), compare_at(&s, scene, Tick::ZERO), 0.0);
|
||||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..scene });
|
||||
r.accel = Some(g);
|
||||
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
|
||||
let mut clip = s.p.clone();
|
||||
clip.settings.hdr = HdrMode::Clip;
|
||||
let want = Renderer::new(&clip, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
|
||||
let d = diff(&want, &img, tolerance(BitDepth::Bpc32));
|
||||
assert_eq!(d.over, 0, "{mode:?}: max {} at {:?}", d.max, d.worst);
|
||||
assert!(img.data.iter().any(|p| p[3] > 0.0 && p[0] / p[3] > 1.0), "{mode:?}: over-range values survive");
|
||||
}
|
||||
}
|
||||
|
||||
/// A GPU context of its own with a small pool budget, so other tests (sharing [`gpu`]) neither
|
||||
/// see the budget nor add to what the pool keeps.
|
||||
fn private_gpu(budget: usize) -> Option<Gpu> {
|
||||
|
||||
@@ -208,6 +208,13 @@ fn on() -> Value {
|
||||
Value::Bool(true)
|
||||
}
|
||||
|
||||
/// #474: every effect done here reads aux channels, so the session gives the media layer its
|
||||
/// footage ([`effectcraft_effects::reads_aux`]).
|
||||
#[test]
|
||||
fn every_effect_here_reads_aux() {
|
||||
assert!(crate::fx_depth::IDS.iter().all(|i| effectcraft_effects::reads_aux(i)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract() {
|
||||
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1200.0))]);
|
||||
|
||||
@@ -184,6 +184,9 @@ fn composites_and_colour() {
|
||||
effect_case("ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(3.0)), ("gamma", n(1.5))]);
|
||||
effect_case("ec.utility.hdrcompression", &[]);
|
||||
effect_case("ec.utility.hdrcompression", &[("amount", n(40.0))]);
|
||||
effect_case("ec.utility.hdrcompression", &[("amount", n(90.0))]);
|
||||
// A NaN Amount keeps the layer (the CPU returns it unchanged).
|
||||
effect_case("ec.utility.hdrcompression", &[("amount", n(f64::NAN))]);
|
||||
effect_case("ec.utility.growbounds", &[("pixels", n(9.0))]);
|
||||
effect_case("ec.utility.ccoverbrights", &[]);
|
||||
for ch in 1..=5 {
|
||||
@@ -191,6 +194,22 @@ fn composites_and_colour() {
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR Highlight Compression on over-range input (HDR Compander expanding first), where the
|
||||
/// compressed channel clips at 1 before the Amount blend.
|
||||
#[test]
|
||||
fn hdr_compression_over_range() {
|
||||
for amount in [100.0, 60.0] {
|
||||
let mut s = Scene::new(BitDepth::Bpc32);
|
||||
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
|
||||
s.push(bg);
|
||||
let mut l = s.footage(70, 44);
|
||||
s.effect(&mut l, "ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(4.0)), ("gamma", n(1.0))]);
|
||||
s.effect(&mut l, "ec.utility.hdrcompression", &[("amount", n(amount))]);
|
||||
s.push(l);
|
||||
check(&format!("hdr compression over range {amount}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylize() {
|
||||
let _flips = allow_llvmpipe_boundary_flips();
|
||||
|
||||
@@ -174,6 +174,7 @@ fn mirror_offset_polar_spherize_corner_pin() {
|
||||
effect_case("ec.distort.spherize", &[("radius", n(35.0)), ("center", pt(30.0, 25.0))]);
|
||||
effect_case("ec.distort.cornerpin", &[("ul", pt(5.0, 3.0)), ("ur", pt(60.0, 8.0)), ("lr", pt(66.0, 40.0)), ("ll", pt(2.0, 44.0))]);
|
||||
effect_case("ec.distort.cornerpin", &[("ul", pt(-8.0, 6.0)), ("ur", pt(75.0, -4.0)), ("lr", pt(50.0, 30.0)), ("ll", pt(12.0, 50.0))]);
|
||||
effect_case("ec.distort.cornerpin", &[("ul", pt(70.0, 55.0)), ("ur", pt(120.0, 55.0)), ("lr", pt(120.0, 90.0)), ("ll", pt(70.0, 90.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -147,16 +147,20 @@ fn object_fill_recovers_background_and_is_stable() {
|
||||
holes.push(hole);
|
||||
gts.push(gt);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let out = fill_sequence_with(input.clone(), &FillOpts::default(), g, &mut |_, _| true).expect("fill");
|
||||
let input = FillInput {
|
||||
frames: effectcraft_raster::inpaint::Frames::Memory(frames),
|
||||
holes: holes.into_iter().map(|h| effectcraft_raster::inpaint::HoleMask::from_vec(&h)).collect(),
|
||||
references: vec![],
|
||||
};
|
||||
let out = fill_sequence_with(input.clone(), &FillOpts::default(), g, &mut |_, _| true, None).expect("fill");
|
||||
for t in 0..out.len() {
|
||||
let db = psnr(&out[t], >s[t], Some(&input.holes[t]));
|
||||
let db = psnr(&out[t], >s[t], Some(&input.holes[t].to_vec()));
|
||||
assert!(db > 26.0, "frame {t} pan {pan}: {db:.1} dB");
|
||||
assert_eq!(out[t].data[0][3], 1.0);
|
||||
}
|
||||
if pan == 0.0 {
|
||||
for t in 1..out.len() {
|
||||
let both: Vec<bool> = input.holes[t].iter().zip(&input.holes[t - 1]).map(|(a, b)| *a || *b).collect();
|
||||
let both: Vec<bool> = input.holes[t].to_vec().iter().zip(input.holes[t - 1].to_vec().iter()).map(|(a, b)| *a || *b).collect();
|
||||
let db = psnr(&out[t], &out[t - 1], Some(&both));
|
||||
assert!(db > 30.0, "frames {}–{t} differ: {db:.1} dB", t - 1);
|
||||
}
|
||||
@@ -172,13 +176,17 @@ fn edge_blend_takes_the_cpu_membrane() {
|
||||
let (w, h) = (80u32, 60u32);
|
||||
let gt = background(w, h, 0.0);
|
||||
let hole = rect_hole(w, h, 30, 20, 14);
|
||||
let input = FillInput { frames: vec![cut(>, &hole); 4], holes: vec![hole.clone(); 4], references: vec![] };
|
||||
let input = FillInput {
|
||||
frames: effectcraft_raster::inpaint::Frames::Memory(vec![cut(>, &hole); 4]),
|
||||
holes: vec![effectcraft_raster::inpaint::HoleMask::from_vec(&hole); 4],
|
||||
references: vec![],
|
||||
};
|
||||
let opts = FillOpts { method: effectcraft_raster::inpaint::FillMethod::EdgeBlend, ..Default::default() };
|
||||
let edge = fill_sequence_with(input.clone(), &opts, g, &mut |_, _| true).expect("fill");
|
||||
let edge = fill_sequence_with(input.clone(), &opts, g, &mut |_, _| true, None).expect("fill");
|
||||
for t in 1..4 {
|
||||
assert!(psnr(&edge[t], &edge[0], Some(&hole)) > 40.0, "frame {t}");
|
||||
}
|
||||
assert!(psnr(&edge[0], &membrane_fill(&input.frames[0], &hole), Some(&hole)) > 60.0);
|
||||
assert!(psnr(&edge[0], &membrane_fill(&input.frames.get(0), &hole), Some(&hole)) > 60.0);
|
||||
}
|
||||
|
||||
/// The dilation the level plans are built from (kept honest: the flags' target bit is `near`).
|
||||
@@ -299,11 +307,15 @@ fn sequence_fill_with_the_device_flow_matches_the_cpu() {
|
||||
frames.push(cut(>, &hole));
|
||||
holes.push(hole);
|
||||
}
|
||||
let input = FillInput { frames, holes, references: vec![] };
|
||||
let dev = fill_sequence_with(input.clone(), &FillOpts::default(), g, &mut |_, _| true).expect("gpu fill");
|
||||
let ref_cpu = fill_sequence_with(input.clone(), &FillOpts::default(), &cpu, &mut |_, _| true).expect("cpu fill");
|
||||
let input = FillInput {
|
||||
frames: effectcraft_raster::inpaint::Frames::Memory(frames),
|
||||
holes: holes.into_iter().map(|h| effectcraft_raster::inpaint::HoleMask::from_vec(&h)).collect(),
|
||||
references: vec![],
|
||||
};
|
||||
let dev = fill_sequence_with(input.clone(), &FillOpts::default(), g, &mut |_, _| true, None).expect("gpu fill");
|
||||
let ref_cpu = fill_sequence_with(input.clone(), &FillOpts::default(), &cpu, &mut |_, _| true, None).expect("cpu fill");
|
||||
for t in 0..dev.len() {
|
||||
let db = psnr(&dev[t], &ref_cpu[t], Some(&input.holes[t]));
|
||||
let db = psnr(&dev[t], &ref_cpu[t], Some(&input.holes[t].to_vec()));
|
||||
assert!(db > 40.0, "frame {t} pan {pan}: device vs CPU {db:.1} dB");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,9 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
|
||||
if let Some(l) = pipe.levels {
|
||||
canvas = ops::quantize(e, &canvas, l);
|
||||
}
|
||||
let out = r.output_pipe();
|
||||
if r.depth() == 0
|
||||
&& let Some(c) = pipe.output()
|
||||
&& let Some(c) = out.output()
|
||||
{
|
||||
canvas = ops::convert(e, &canvas, &c);
|
||||
if let Some(l) = pipe.levels {
|
||||
@@ -54,7 +55,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
|
||||
}
|
||||
}
|
||||
if r.depth() == 0
|
||||
&& let Some((lin, mode, enc)) = pipe.output_hdr()
|
||||
&& let Some((lin, mode, enc)) = out.output_hdr()
|
||||
{
|
||||
on_cpu(e, &mut canvas, |img| {
|
||||
effectcraft_render::color::output_hdr(img, lin, mode, enc);
|
||||
|
||||
+34
-18
@@ -376,30 +376,20 @@ fn convert_yuv<T: Sample>(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op
|
||||
pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image {
|
||||
use image::DynamicImage as D;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if let D::ImageRgb32F(_) | D::ImageRgba32F(_) = img {
|
||||
let px = img.to_rgba32f();
|
||||
let raw = px.as_raw();
|
||||
return float_to_image(w, h, op, |i| {
|
||||
let s = &raw[i * 4..i * 4 + 4];
|
||||
[s[0], s[1], s[2], s[3]]
|
||||
});
|
||||
}
|
||||
let mut out = Image::new(w, h);
|
||||
let wu = w as usize;
|
||||
if wu == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
match img {
|
||||
D::ImageRgb32F(_) | D::ImageRgba32F(_) => {
|
||||
let px = img.to_rgba32f();
|
||||
let raw = px.as_raw();
|
||||
let premul = op.mode == AlphaMode::Premultiplied;
|
||||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
|
||||
let a = s[3];
|
||||
let c = if premul {
|
||||
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||||
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
|
||||
} else {
|
||||
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
|
||||
};
|
||||
*o = op.apply(c, a.clamp(0.0, 1.0));
|
||||
}
|
||||
});
|
||||
}
|
||||
D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => {
|
||||
let px = img.to_rgba16();
|
||||
let raw = px.as_raw();
|
||||
@@ -425,6 +415,32 @@ pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image
|
||||
out
|
||||
}
|
||||
|
||||
/// Float RGBA (linear light, alpha straight or premultiplied as the footage says), `px(i)` for
|
||||
/// pixel `i` in rows, to an [`Image`]: how [`dynamic_to_image`] converts float stills, also used
|
||||
/// for OpenEXR pictures made from decoded channel planes without an interleaved copy (#474).
|
||||
pub(crate) fn float_to_image(w: u32, h: u32, op: AlphaOp, px: impl Fn(usize) -> [f32; 4] + Sync) -> Image {
|
||||
let mut out = Image::new(w, h);
|
||||
let wu = w as usize;
|
||||
if wu == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
let premul = op.mode == AlphaMode::Premultiplied;
|
||||
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let s = px(y * wu + x);
|
||||
let a = s[3];
|
||||
let c = if premul {
|
||||
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||||
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
|
||||
} else {
|
||||
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
|
||||
};
|
||||
*o = op.apply(c, a.clamp(0.0, 1.0));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Straight RGBA `f32` pixels (layered stills: Photoshop, SVG) to an [`Image`].
|
||||
pub(crate) fn straight_to_image(w: u32, h: u32, px: &[[f32; 4]], op: AlphaOp) -> Image {
|
||||
let mut out = Image::new(w, h);
|
||||
|
||||
+979
-140
File diff suppressed because it is too large.
Load diff
@@ -245,6 +245,25 @@ struct Inner {
|
||||
/// Movies that failed to open or decode (path to a readable reason; see
|
||||
/// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not.
|
||||
problems: Mutex<HashMap<Arc<str>, String>>,
|
||||
/// OpenEXR footage (by path) whose channels effects read (EXtractoR, Cryptomatte, the 3D
|
||||
/// Channel effects): its pictures come from the same cached decode (#474).
|
||||
aux_footage: Mutex<AuxFootage>,
|
||||
}
|
||||
|
||||
/// Footage whose channels effects read ([`Inner::aux_footage`]), by [`Footage::path`].
|
||||
#[derive(Default)]
|
||||
struct AuxFootage {
|
||||
/// What the project says ([`FootageSource::set_aux_footage`]).
|
||||
given: HashSet<String>,
|
||||
/// Read through [`FootageSource::aux`] but not given (a reader the project doesn't name, or
|
||||
/// a source that never gives a set). Cleared with each new set.
|
||||
learned: HashSet<String>,
|
||||
}
|
||||
|
||||
impl AuxFootage {
|
||||
fn contains(&self, path: &str) -> bool {
|
||||
self.given.contains(path) || self.learned.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
|
||||
@@ -315,6 +334,7 @@ impl MediaPool {
|
||||
conforming: Mutex::default(),
|
||||
conform_failed: Mutex::default(),
|
||||
problems: Mutex::default(),
|
||||
aux_footage: Mutex::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -856,13 +876,22 @@ impl Inner {
|
||||
{
|
||||
return Err(MediaError::Decode(format!("{path}: {why}")));
|
||||
}
|
||||
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
|
||||
let img = match loaded {
|
||||
Ok(img) => img,
|
||||
// An effect reads this footage's channels: the picture is made of the same decode (#474).
|
||||
let shared = lock(&self.aux_footage).contains(&footage.path);
|
||||
if shared
|
||||
&& path.to_ascii_lowercase().ends_with(".exr")
|
||||
&& let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op)
|
||||
{
|
||||
return Ok(img);
|
||||
}
|
||||
#[cfg(test)]
|
||||
crate::exr_channels::count_decode(false);
|
||||
match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) {
|
||||
Ok(img) => Ok(dynamic_to_image(&img, op)),
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
|
||||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared)
|
||||
.map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
@@ -953,6 +982,16 @@ impl FootageSource for MediaPool {
|
||||
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new)
|
||||
}
|
||||
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
let given: HashSet<String> = paths.iter().cloned().collect();
|
||||
let mut wanted = lock(&self.inner.aux_footage);
|
||||
if given != wanted.given || !wanted.learned.is_empty() {
|
||||
crate::exr_channels::release_held();
|
||||
}
|
||||
wanted.given = given;
|
||||
wanted.learned.clear();
|
||||
}
|
||||
|
||||
fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
if footage.missing || !footage.has_video {
|
||||
return None;
|
||||
@@ -962,6 +1001,16 @@ impl FootageSource for MediaPool {
|
||||
return None;
|
||||
}
|
||||
let path = loc.key.path.clone();
|
||||
{
|
||||
let mut wanted = lock(&self.inner.aux_footage);
|
||||
if !wanted.contains(&footage.path) {
|
||||
// (a bound: names of footage items, cleared otherwise only by a new set)
|
||||
if wanted.learned.len() >= 4096 {
|
||||
wanted.learned.clear();
|
||||
}
|
||||
wanted.learned.insert(footage.path.clone());
|
||||
}
|
||||
}
|
||||
crate::exr_channels::cached(&path, || self.inner.read(&path).ok())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! OpenEXR pictures and aux channels pinned to exact hashes (#474).
|
||||
//!
|
||||
//! Synthetic files (single-layer, multi-layer with and without an unnamed RGB layer, multi-part,
|
||||
//! data windows smaller and larger than the display window, half / float / uint channels, RLE,
|
||||
//! ZIP, PIZ and DWAA) are decoded through the media pool as the renderer asks for them: the
|
||||
//! picture in each alpha interpretation, and the channels EXtractoR and Cryptomatte read. The
|
||||
//! hashes were taken before the picture and the channels started sharing one decode, so a change
|
||||
//! to how either is decoded must leave every bit as it was. Only bit-exact float steps are
|
||||
//! pinned (linear light, no `powf`), so the hashes hold on every platform; DWAA (whose decoder
|
||||
//! uses `powf`) and the sRGB / gamma 2.4 curves are checked for agreement between the two ways a
|
||||
//! frame can be decoded instead. Re-pin with `EXR_GOLDEN_PRINT=1` after an intended change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::{AlphaMode, Footage, ItemId};
|
||||
use effectcraft_render::{FloatCurve, FootageSource};
|
||||
use effectcraft_time::Tick;
|
||||
use exr::prelude::*;
|
||||
|
||||
const W: usize = 37;
|
||||
const H: usize = 23;
|
||||
|
||||
/// A deterministic value for pixel (x, y) of channel `c`: negative, in range and above 1.
|
||||
fn value(x: usize, y: usize, c: usize) -> f32 {
|
||||
((x * 7 + y * 13 + c * 29) % 97) as f32 / 40.0 - 0.3
|
||||
}
|
||||
|
||||
/// A deterministic alpha in 0..=1 (0 included, for the premultiplied divide).
|
||||
fn alpha(x: usize, y: usize) -> f32 {
|
||||
((x * 3 + y * 5) % 11) as f32 / 10.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Half,
|
||||
Float,
|
||||
Uint,
|
||||
}
|
||||
|
||||
/// A channel `name` of `size` filled from [`value`] (or [`alpha`] for an `A` / `a` channel).
|
||||
fn channel(name: &str, kind: Kind, size: (usize, usize), c: usize) -> AnyChannel<FlatSamples> {
|
||||
let is_alpha = name.rsplit('.').next().is_some_and(|n| n.eq_ignore_ascii_case("a"));
|
||||
let px = |i: usize| {
|
||||
let (x, y) = (i % size.0, i / size.0);
|
||||
if is_alpha { alpha(x, y) } else { value(x, y, c) }
|
||||
};
|
||||
let n = size.0 * size.1;
|
||||
let samples = match kind {
|
||||
Kind::Half => FlatSamples::F16((0..n).map(|i| f16::from_f32(px(i))).collect()),
|
||||
Kind::Float => FlatSamples::F32((0..n).map(px).collect()),
|
||||
Kind::Uint => FlatSamples::U32((0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) >> 8).collect()),
|
||||
};
|
||||
AnyChannel::new(name, samples)
|
||||
}
|
||||
|
||||
fn channels(list: &[(&str, Kind)], size: (usize, usize)) -> AnyChannels<FlatSamples> {
|
||||
AnyChannels::sort(list.iter().enumerate().map(|(c, (n, k))| channel(n, *k, size, c)).collect::<Vec<_>>().into())
|
||||
}
|
||||
|
||||
fn encoding(compression: Compression) -> Encoding {
|
||||
Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }
|
||||
}
|
||||
|
||||
fn with_manifest(mut attrs: LayerAttributes) -> LayerAttributes {
|
||||
attrs.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoObject")));
|
||||
attrs.other.insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"bunny":"13851a76","floor":"0a1b2c3d"}"#)));
|
||||
attrs
|
||||
}
|
||||
|
||||
/// One part: `list` channels over a data window of `size` at `pos`.
|
||||
fn part(name: Option<&str>, list: &[(&str, Kind)], size: (usize, usize), pos: (i32, i32), compression: Compression) -> Layer<AnyChannels<FlatSamples>> {
|
||||
part_with(name, list, size, pos, encoding(compression))
|
||||
}
|
||||
|
||||
/// [`part`] with its own block layout (tiles) and line order.
|
||||
fn part_with(name: Option<&str>, list: &[(&str, Kind)], size: (usize, usize), pos: (i32, i32), encoding: Encoding) -> Layer<AnyChannels<FlatSamples>> {
|
||||
let mut attrs = name.map_or_else(LayerAttributes::default, LayerAttributes::named);
|
||||
attrs.layer_position = Vec2(pos.0, pos.1);
|
||||
Layer::new(size, with_manifest(attrs), encoding, channels(list, size))
|
||||
}
|
||||
|
||||
/// A tiled single-part file of `list` with mip levels down to 1×1 (each level its own values);
|
||||
/// the picture and the channels are its largest level.
|
||||
fn mipmapped(list: &[(&str, Kind)], compression: Compression) -> Vec<u8> {
|
||||
let levels = |name: &str, kind: Kind, c: usize| {
|
||||
let (mut data, mut size) = (vec![], (W, H));
|
||||
loop {
|
||||
data.push(channel(name, kind, size, c + data.len()).sample_data);
|
||||
if size == (1, 1) {
|
||||
break;
|
||||
}
|
||||
size = ((size.0 / 2).max(1), (size.1 / 2).max(1));
|
||||
}
|
||||
AnyChannel::new(name, Levels::Mip { rounding_mode: exr::math::RoundingMode::Down, level_data: data })
|
||||
};
|
||||
let list: Vec<AnyChannel<Levels<FlatSamples>>> = list.iter().enumerate().map(|(c, (n, k))| levels(n, *k, c)).collect();
|
||||
let encoding = Encoding { compression, blocks: Blocks::Tiles(Vec2(16, 16)), line_order: LineOrder::Increasing };
|
||||
let layer = Layer::new((W, H), with_manifest(LayerAttributes::default()), encoding, AnyChannels::sort(list.into()));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
Image::from_layer(layer).write().non_parallel().to_buffered(&mut bytes).unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
/// Encode `layers` with the display window `display` (position, size).
|
||||
fn write(layers: Vec<Layer<AnyChannels<FlatSamples>>>, display: ((i32, i32), (usize, usize))) -> Vec<u8> {
|
||||
let mut attrs = ImageAttributes::new(IntegerBounds::new(display.0, display.1));
|
||||
attrs.pixel_aspect = 1.0;
|
||||
let image = Image::from_layers(attrs, layers);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write().non_parallel().to_buffered(&mut bytes).unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
const RGBA_HALF: [(&str, Kind); 4] = [("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half), ("A", Kind::Half)];
|
||||
const CRYPTO: [(&str, Kind); 4] =
|
||||
[("CryptoObject00.r", Kind::Float), ("CryptoObject00.g", Kind::Float), ("CryptoObject00.b", Kind::Float), ("CryptoObject00.a", Kind::Float)];
|
||||
|
||||
/// The synthetic files: (name, bytes, whether the hashes are pinned).
|
||||
fn files() -> Vec<(&'static str, Vec<u8>, bool)> {
|
||||
let full = (W, H);
|
||||
let origin = ((0, 0), full);
|
||||
let cat = |a: &[(&'static str, Kind)], b: &[(&'static str, Kind)]| -> Vec<(&'static str, Kind)> { a.iter().chain(b).copied().collect() };
|
||||
let layered: Vec<(&str, Kind)> = cat(
|
||||
&[
|
||||
("Image.R", Kind::Half),
|
||||
("Image.G", Kind::Half),
|
||||
("Image.B", Kind::Half),
|
||||
("Image.A", Kind::Half),
|
||||
("depth.Z", Kind::Float),
|
||||
("ObjectID", Kind::Uint),
|
||||
],
|
||||
&CRYPTO,
|
||||
);
|
||||
let unnamed_plus: Vec<(&str, Kind)> =
|
||||
cat(&cat(&RGBA_HALF, &[("Diffuse.R", Kind::Half), ("Diffuse.G", Kind::Half), ("Diffuse.B", Kind::Half), ("Z", Kind::Float)]), &CRYPTO);
|
||||
let rgb_float = [("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("depth.Z", Kind::Float)];
|
||||
vec![
|
||||
("rgba_half_zip16", write(vec![part(None, &RGBA_HALF, full, (0, 0), Compression::ZIP16)], origin), true),
|
||||
(
|
||||
"rgb_float_piz",
|
||||
write(vec![part(None, &[("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("Z", Kind::Float)], full, (0, 0), Compression::PIZ)], origin),
|
||||
true,
|
||||
),
|
||||
("layered_rle", write(vec![part(None, &layered, full, (0, 0), Compression::RLE)], origin), true),
|
||||
("unnamed_rgb_plus_layers_zip1", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::ZIP1)], origin), true),
|
||||
(
|
||||
"multipart_zip16",
|
||||
write(
|
||||
vec![
|
||||
part(Some("Image"), &RGBA_HALF, full, (0, 0), Compression::ZIP16),
|
||||
part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP16),
|
||||
part(
|
||||
Some("CryptoObject00"),
|
||||
&[("r", Kind::Float), ("g", Kind::Float), ("b", Kind::Float), ("a", Kind::Float)],
|
||||
full,
|
||||
(0, 0),
|
||||
Compression::ZIP16,
|
||||
),
|
||||
],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
// The first part with R, G and B is not the beauty pass.
|
||||
"multipart_diffuse_then_beauty_zip1",
|
||||
write(
|
||||
vec![
|
||||
part(Some("diffuse"), &[("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half)], full, (0, 0), Compression::ZIP1),
|
||||
part(Some("beauty"), &RGBA_HALF, (20, 10), (5, 7), Compression::ZIP1),
|
||||
part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP1),
|
||||
],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"multipart_blender5_rle",
|
||||
write(
|
||||
vec![
|
||||
part(Some("ViewLayer.Depth"), &[("ViewLayer.Depth.Z", Kind::Float)], full, (0, 0), Compression::RLE),
|
||||
part(
|
||||
Some("ViewLayer.Combined"),
|
||||
&[
|
||||
("ViewLayer.Combined.R", Kind::Half),
|
||||
("ViewLayer.Combined.G", Kind::Half),
|
||||
("ViewLayer.Combined.B", Kind::Half),
|
||||
("ViewLayer.Combined.A", Kind::Half),
|
||||
],
|
||||
full,
|
||||
(0, 0),
|
||||
Compression::RLE,
|
||||
),
|
||||
],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
("unnamed_rgb_window_smaller_zip16", write(vec![part(None, &unnamed_plus, (20, 10), (5, 7), Compression::ZIP16)], origin), true),
|
||||
("rgb_float_window_larger_piz", write(vec![part(None, &rgb_float, (45, 30), (-1, -5), Compression::PIZ)], ((3, -2), full)), true),
|
||||
("layered_window_smaller_zip16", write(vec![part(None, &layered, (20, 10), (5, 7), Compression::ZIP16)], origin), true),
|
||||
("layered_window_larger_piz", write(vec![part(None, &layered, (45, 30), (-4, -3), Compression::PIZ)], origin), true),
|
||||
("unnamed_rgb_plus_layers_dwaa", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::DWAA(None))], origin), false),
|
||||
("layered_dwaa", write(vec![part(None, &layered, full, (0, 0), Compression::DWAA(None))], origin), false),
|
||||
(
|
||||
"unnamed_rgb_plus_layers_tiled_zip16",
|
||||
write(
|
||||
vec![part_with(None, &unnamed_plus, (30, 20), (3, 2), Encoding { blocks: Blocks::Tiles(Vec2(16, 8)), ..encoding(Compression::ZIP16) })],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"layered_tiled_rle",
|
||||
write(vec![part_with(None, &layered, full, (0, 0), Encoding { blocks: Blocks::Tiles(Vec2(8, 8)), ..encoding(Compression::RLE) })], origin),
|
||||
true,
|
||||
),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", mipmapped(&unnamed_plus, Compression::ZIP16), true),
|
||||
("layered_mipmapped_piz", mipmapped(&layered, Compression::PIZ), true),
|
||||
(
|
||||
"unnamed_rgb_plus_layers_decreasing_zip1",
|
||||
write(
|
||||
vec![part_with(None, &unnamed_plus, (20, 10), (5, 7), Encoding { line_order: LineOrder::Decreasing, ..encoding(Compression::ZIP1) })],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"layered_decreasing_rle",
|
||||
write(vec![part_with(None, &layered, full, (0, 0), Encoding { line_order: LineOrder::Decreasing, ..encoding(Compression::RLE) })], origin),
|
||||
true,
|
||||
),
|
||||
("unnamed_rgb_plus_layers_uncompressed", write(vec![part(None, &unnamed_plus, (20, 10), (-3, 4), Compression::Uncompressed)], origin), true),
|
||||
("layered_uncompressed", write(vec![part(None, &layered, full, (0, 0), Compression::Uncompressed)], origin), true),
|
||||
(
|
||||
"uint_rgb_zip16",
|
||||
write(
|
||||
vec![part(
|
||||
None,
|
||||
&[("R", Kind::Uint), ("G", Kind::Uint), ("B", Kind::Uint), ("A", Kind::Half), ("depth.Z", Kind::Float)],
|
||||
full,
|
||||
(0, 0),
|
||||
Compression::ZIP16,
|
||||
)],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
// The image crate shows the part "depth" (the first with R, G and B), whose planes are
|
||||
// filled with the background depth outside its data window: decoded by the image crate.
|
||||
"multipart_depth_named_rgb_zip16",
|
||||
write(
|
||||
vec![
|
||||
part(Some("depth"), &[("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half)], (20, 10), (5, 7), Compression::ZIP16),
|
||||
part(Some("beauty"), &RGBA_HALF, full, (0, 0), Compression::ZIP16),
|
||||
],
|
||||
origin,
|
||||
),
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// FNV-1a over 32-bit words.
|
||||
fn fnv(words: impl IntoIterator<Item = u32>) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for w in words {
|
||||
for b in w.to_le_bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn picture_hash(pool: &MediaPool, f: &Footage, float: FloatCurve, size: Option<(u32, u32)>) -> u64 {
|
||||
let img = pool.frame_with(ItemId(1), f, Tick::ZERO, size, float).expect("picture");
|
||||
fnv([img.width, img.height].into_iter().chain(img.data.iter().flat_map(|p| p.map(f32::to_bits))))
|
||||
}
|
||||
|
||||
fn aux_hash(pool: &MediaPool, f: &Footage) -> u64 {
|
||||
let Some(aux) = pool.aux(ItemId(1), f, Tick::ZERO) else { return 0 };
|
||||
let mut words = vec![aux.width, aux.height, aux.scale.to_bits() as u32];
|
||||
for (name, plane) in &aux.channels {
|
||||
words.extend(name.bytes().map(u32::from));
|
||||
words.extend(plane.iter().map(|v| v.to_bits()));
|
||||
}
|
||||
for (name, objects) in &aux.manifests {
|
||||
words.extend(name.bytes().map(u32::from));
|
||||
for (o, h) in objects {
|
||||
words.extend(o.bytes().map(u32::from));
|
||||
words.push(*h);
|
||||
}
|
||||
}
|
||||
fnv(words)
|
||||
}
|
||||
|
||||
/// The interpretations a picture is decoded in: (label, footage, curve, size, pinned).
|
||||
fn variants(f: &Footage) -> Vec<(String, Footage, FloatCurve, Option<(u32, u32)>, bool)> {
|
||||
let mut out = vec![];
|
||||
for (mode, label) in
|
||||
[(None, "probed"), (Some(AlphaMode::Straight), "straight"), (Some(AlphaMode::Premultiplied), "premul"), (Some(AlphaMode::Ignore), "ignore")]
|
||||
{
|
||||
let mut g = f.clone();
|
||||
if let Some(m) = mode {
|
||||
g.alpha = m;
|
||||
}
|
||||
g.premul_color = [0.2, 0.4, 0.6];
|
||||
out.push((format!("{label}/linear"), g.clone(), FloatCurve::Linear, None, true));
|
||||
out.push((format!("{label}/srgb"), g.clone(), FloatCurve::Srgb, None, false));
|
||||
out.push((format!("{label}/gamma24"), g.clone(), FloatCurve::Gamma24, None, false));
|
||||
}
|
||||
let preserve = Footage { preserve_rgb: true, ..f.clone() };
|
||||
out.push(("preserve_rgb".into(), preserve, FloatCurve::Srgb, None, true));
|
||||
out.push(("half_size/srgb".into(), f.clone(), FloatCurve::Srgb, Some((f.width.div_ceil(2), f.height.div_ceil(2))), false));
|
||||
out
|
||||
}
|
||||
|
||||
/// Every hash of `file`: the size and alpha import finds, each picture and the channels, decoded
|
||||
/// picture first (the footage alone) or channels first (an effect reads them).
|
||||
fn hashes(name: &str, bytes: &[u8], aux_first: bool) -> Vec<(String, u64, bool)> {
|
||||
let order = if aux_first { "aux-first" } else { "picture-first" };
|
||||
let path = format!("/exr-golden-474/{order}/{name}.exr");
|
||||
let b: Arc<[u8]> = bytes.into();
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&path, b.clone());
|
||||
let f = probe_bytes(&path, b).expect("probe");
|
||||
let mut out = vec![("probe".to_string(), fnv([f.width, f.height, f.alpha as u32]), true)];
|
||||
let aux = if aux_first { Some(aux_hash(&pool, &f)) } else { None };
|
||||
for (label, g, float, size, pinned) in variants(&f) {
|
||||
out.push((label, picture_hash(&pool, &g, float, size), pinned));
|
||||
}
|
||||
out.push(("aux".into(), aux.unwrap_or_else(|| aux_hash(&pool, &f)), true));
|
||||
out
|
||||
}
|
||||
|
||||
/// Hashes taken on main before #474's shared decode (`EXR_GOLDEN_PRINT=1`).
|
||||
const PINNED: &[(&str, &str, u64)] = &[
|
||||
("rgba_half_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("rgba_half_zip16", "probed/linear", 0xa84600283dce98f6),
|
||||
("rgba_half_zip16", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("rgba_half_zip16", "premul/linear", 0xa84600283dce98f6),
|
||||
("rgba_half_zip16", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("rgba_half_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("rgba_half_zip16", "aux", 0xdd555e6cf67d75f3),
|
||||
("rgb_float_piz", "probe", 0x6a38346c4c5662d5),
|
||||
("rgb_float_piz", "probed/linear", 0xa6e8fbbad371a2ae),
|
||||
("rgb_float_piz", "straight/linear", 0xa6e8fbbad371a2ae),
|
||||
("rgb_float_piz", "premul/linear", 0x515f476c176229c9),
|
||||
("rgb_float_piz", "ignore/linear", 0xa6e8fbbad371a2ae),
|
||||
("rgb_float_piz", "preserve_rgb", 0xa6e8fbbad371a2ae),
|
||||
("rgb_float_piz", "aux", 0x554623d237c2e5ef),
|
||||
("layered_rle", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_rle", "probed/linear", 0xa84600283dce98f6),
|
||||
("layered_rle", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("layered_rle", "premul/linear", 0xa84600283dce98f6),
|
||||
("layered_rle", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("layered_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("layered_rle", "aux", 0xa6b9bd9f02c192ad),
|
||||
("unnamed_rgb_plus_layers_zip1", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_plus_layers_zip1", "probed/linear", 0xa84600283dce98f6),
|
||||
("unnamed_rgb_plus_layers_zip1", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("unnamed_rgb_plus_layers_zip1", "premul/linear", 0xa84600283dce98f6),
|
||||
("unnamed_rgb_plus_layers_zip1", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("unnamed_rgb_plus_layers_zip1", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("unnamed_rgb_plus_layers_zip1", "aux", 0x27cc9b53d0862db1),
|
||||
("multipart_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("multipart_zip16", "probed/linear", 0xa84600283dce98f6),
|
||||
("multipart_zip16", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("multipart_zip16", "premul/linear", 0xa84600283dce98f6),
|
||||
("multipart_zip16", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("multipart_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("multipart_zip16", "aux", 0x567ad99a6690d03d),
|
||||
("multipart_diffuse_then_beauty_zip1", "probe", 0x6a38346c4c5662d5),
|
||||
("multipart_diffuse_then_beauty_zip1", "probed/linear", 0xa90a57f9a67f7468),
|
||||
("multipart_diffuse_then_beauty_zip1", "straight/linear", 0xa90a57f9a67f7468),
|
||||
("multipart_diffuse_then_beauty_zip1", "premul/linear", 0x7ab18932e25c5cc8),
|
||||
("multipart_diffuse_then_beauty_zip1", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("multipart_diffuse_then_beauty_zip1", "preserve_rgb", 0xa90a57f9a67f7468),
|
||||
("multipart_diffuse_then_beauty_zip1", "aux", 0x6724d6dfd56a04cb),
|
||||
("multipart_blender5_rle", "probe", 0x0a32e074a28c1f66),
|
||||
("multipart_blender5_rle", "probed/linear", 0xa84600283dce98f6),
|
||||
("multipart_blender5_rle", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("multipart_blender5_rle", "premul/linear", 0xa84600283dce98f6),
|
||||
("multipart_blender5_rle", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("multipart_blender5_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("multipart_blender5_rle", "aux", 0x522ae4819dd92e89),
|
||||
("unnamed_rgb_window_smaller_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7),
|
||||
("unnamed_rgb_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420),
|
||||
("unnamed_rgb_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7),
|
||||
("unnamed_rgb_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0),
|
||||
("unnamed_rgb_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2),
|
||||
("unnamed_rgb_window_smaller_zip16", "aux", 0x8f78d3233450acf3),
|
||||
("rgb_float_window_larger_piz", "probe", 0x6a38346c4c5662d5),
|
||||
("rgb_float_window_larger_piz", "probed/linear", 0x8e3847d48f9967a6),
|
||||
("rgb_float_window_larger_piz", "straight/linear", 0x8e3847d48f9967a6),
|
||||
("rgb_float_window_larger_piz", "premul/linear", 0x0998eefc456dcdc9),
|
||||
("rgb_float_window_larger_piz", "ignore/linear", 0x8e3847d48f9967a6),
|
||||
("rgb_float_window_larger_piz", "preserve_rgb", 0x8e3847d48f9967a6),
|
||||
("rgb_float_window_larger_piz", "aux", 0x7ccc9f86e6ff6140),
|
||||
("layered_window_smaller_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7),
|
||||
("layered_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420),
|
||||
("layered_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7),
|
||||
("layered_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0),
|
||||
("layered_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2),
|
||||
("layered_window_smaller_zip16", "aux", 0x443480a1d789e63c),
|
||||
("layered_window_larger_piz", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_window_larger_piz", "probed/linear", 0x443afe4029748836),
|
||||
("layered_window_larger_piz", "straight/linear", 0xb32b44880f746826),
|
||||
("layered_window_larger_piz", "premul/linear", 0x443afe4029748836),
|
||||
("layered_window_larger_piz", "ignore/linear", 0xaa01706accce2cc4),
|
||||
("layered_window_larger_piz", "preserve_rgb", 0xb2cbb40b10bc56b4),
|
||||
("layered_window_larger_piz", "aux", 0xb7d91c8eea56a772),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "probed/linear", 0x7612f20634fe957e),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "straight/linear", 0xaa5f42cb6a31c78a),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "premul/linear", 0x7612f20634fe957e),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "ignore/linear", 0xbbf236635dcaac90),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "preserve_rgb", 0x15b555a243d79613),
|
||||
("unnamed_rgb_plus_layers_tiled_zip16", "aux", 0x3d1439eed2202e41),
|
||||
("layered_tiled_rle", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_tiled_rle", "probed/linear", 0xa84600283dce98f6),
|
||||
("layered_tiled_rle", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("layered_tiled_rle", "premul/linear", 0xa84600283dce98f6),
|
||||
("layered_tiled_rle", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("layered_tiled_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("layered_tiled_rle", "aux", 0xa6b9bd9f02c192ad),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "probed/linear", 0xa84600283dce98f6),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "premul/linear", 0xa84600283dce98f6),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("unnamed_rgb_plus_layers_mipmapped_zip16", "aux", 0x27cc9b53d0862db1),
|
||||
("layered_mipmapped_piz", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_mipmapped_piz", "probed/linear", 0xa84600283dce98f6),
|
||||
("layered_mipmapped_piz", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("layered_mipmapped_piz", "premul/linear", 0xa84600283dce98f6),
|
||||
("layered_mipmapped_piz", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("layered_mipmapped_piz", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("layered_mipmapped_piz", "aux", 0xa6b9bd9f02c192ad),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "probed/linear", 0x19eb3b1a88d108a7),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "straight/linear", 0x2c73f9acaa936420),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "premul/linear", 0x19eb3b1a88d108a7),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "ignore/linear", 0x3b86057d18a16bf0),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "preserve_rgb", 0x5b999014e4a64bb2),
|
||||
("unnamed_rgb_plus_layers_decreasing_zip1", "aux", 0x8f78d3233450acf3),
|
||||
("layered_decreasing_rle", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_decreasing_rle", "probed/linear", 0xa84600283dce98f6),
|
||||
("layered_decreasing_rle", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("layered_decreasing_rle", "premul/linear", 0xa84600283dce98f6),
|
||||
("layered_decreasing_rle", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("layered_decreasing_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("layered_decreasing_rle", "aux", 0xa6b9bd9f02c192ad),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "probe", 0x0a32e074a28c1f66),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "probed/linear", 0xa27668bf31d9426b),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "straight/linear", 0xfc73fe89457ce029),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "premul/linear", 0xa27668bf31d9426b),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "ignore/linear", 0xb3276cc5126776d4),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "preserve_rgb", 0xdee1105fa6e6822a),
|
||||
("unnamed_rgb_plus_layers_uncompressed", "aux", 0x854a5e84e7aaa5c2),
|
||||
("layered_uncompressed", "probe", 0x0a32e074a28c1f66),
|
||||
("layered_uncompressed", "probed/linear", 0xa84600283dce98f6),
|
||||
("layered_uncompressed", "straight/linear", 0xf028e1367a2fbb30),
|
||||
("layered_uncompressed", "premul/linear", 0xa84600283dce98f6),
|
||||
("layered_uncompressed", "ignore/linear", 0xa90a57f9a67f7468),
|
||||
("layered_uncompressed", "preserve_rgb", 0xc8e0050f5efe75b7),
|
||||
("layered_uncompressed", "aux", 0xa6b9bd9f02c192ad),
|
||||
("uint_rgb_zip16", "probe", 0x0a32e074a28c1f66),
|
||||
("uint_rgb_zip16", "probed/linear", 0xed89310ef0cdb9fd),
|
||||
("uint_rgb_zip16", "straight/linear", 0xf9fe11ab8ed97cc8),
|
||||
("uint_rgb_zip16", "premul/linear", 0xed89310ef0cdb9fd),
|
||||
("uint_rgb_zip16", "ignore/linear", 0xf78cd263c0d74f12),
|
||||
("uint_rgb_zip16", "preserve_rgb", 0x2a19d67b07cf4359),
|
||||
("uint_rgb_zip16", "aux", 0x03e42411f8521856),
|
||||
("multipart_depth_named_rgb_zip16", "probe", 0x6a38346c4c5662d5),
|
||||
("multipart_depth_named_rgb_zip16", "probed/linear", 0x3b86057d18a16bf0),
|
||||
("multipart_depth_named_rgb_zip16", "straight/linear", 0x3b86057d18a16bf0),
|
||||
("multipart_depth_named_rgb_zip16", "premul/linear", 0x869374e9b0384cea),
|
||||
("multipart_depth_named_rgb_zip16", "ignore/linear", 0x3b86057d18a16bf0),
|
||||
("multipart_depth_named_rgb_zip16", "preserve_rgb", 0x3b86057d18a16bf0),
|
||||
("multipart_depth_named_rgb_zip16", "aux", 0x0fa56884e83b4417),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn exr_pictures_and_channels_are_bit_identical() {
|
||||
let print = std::env::var_os("EXR_GOLDEN_PRINT").is_some();
|
||||
let mut failures = vec![];
|
||||
for (name, bytes, pinned_file) in files() {
|
||||
let first = hashes(name, &bytes, false);
|
||||
let second = hashes(name, &bytes, true);
|
||||
for ((label, h, pinned), (_, h2, _)) in first.iter().zip(&second) {
|
||||
if h != h2 {
|
||||
failures.push(format!("{name} {label}: picture first {h:#018x}, channels first {h2:#018x}"));
|
||||
}
|
||||
if print {
|
||||
println!(" (\"{name}\", \"{label}\", {h:#018x}),{}", if *pinned && pinned_file { "" } else { " // not pinned" });
|
||||
continue;
|
||||
}
|
||||
if !(*pinned && pinned_file) {
|
||||
continue;
|
||||
}
|
||||
match PINNED.iter().find(|(n, l, _)| *n == name && l == label) {
|
||||
Some((_, _, want)) if want == h => {}
|
||||
Some((_, _, want)) => failures.push(format!("{name} {label}: {h:#018x}, pinned {want:#018x}")),
|
||||
None => failures.push(format!("{name} {label}: {h:#018x} is not pinned")),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod aac_layout;
|
||||
mod downmix;
|
||||
mod exif_orientation;
|
||||
mod exr_golden;
|
||||
mod file_timecode;
|
||||
mod footage;
|
||||
mod open_in_place;
|
||||
|
||||
@@ -13,3 +13,4 @@ workspace = true
|
||||
effectcraft-geom = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
+1138
-95
File diff suppressed because it is too large.
Load diff
@@ -197,6 +197,13 @@ impl<'a> Renderer<'a> {
|
||||
self.pipe
|
||||
}
|
||||
|
||||
/// The colour pipeline of the top-level output steps: the project's, with no HDR step under
|
||||
/// [`crate::RenderOpts::skip_hdr_step`] (the consumer decodes with
|
||||
/// [`color::Pipe::output_linear`] or applies the step itself).
|
||||
pub fn output_pipe(&self) -> color::Pipe {
|
||||
if self.opts.skip_hdr_step { self.pipe.without_hdr_step() } else { self.pipe }
|
||||
}
|
||||
|
||||
/// Nesting depth (0 = the top-level comp).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.depth
|
||||
|
||||
@@ -117,6 +117,20 @@ impl Pipe {
|
||||
Conversion::new(ws, self.linear, self.out, false)
|
||||
}
|
||||
|
||||
/// The pipe without the HDR step (Clip), as [`crate::RenderOpts::skip_hdr_step`] renders
|
||||
/// with: [`Pipe::output`] converts to the output space as it is.
|
||||
pub fn without_hdr_step(self) -> Pipe {
|
||||
Pipe { hdr: HdrMode::Clip, ..self }
|
||||
}
|
||||
|
||||
/// The top-level frame of a [`Pipe::without_hdr_step`] render (output-encoded, straight colour)
|
||||
/// → linear light in the output space's primaries: the output space's curve, or the Working
|
||||
/// Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4). The identity for a
|
||||
/// linear output (ACES).
|
||||
pub fn output_linear(&self) -> Conversion {
|
||||
Conversion::linearize(if self.space.is_some() { self.out } else { self.curve() })
|
||||
}
|
||||
|
||||
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
|
||||
/// space → linear output primaries, the HDR curve, then the output's encoding.
|
||||
pub fn output_hdr(&self) -> Option<(Option<Conversion>, HdrMode, Conversion)> {
|
||||
|
||||
@@ -48,6 +48,12 @@ pub trait FootageSource: Send + Sync {
|
||||
fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
None
|
||||
}
|
||||
/// The footage files (by [`Footage::path`]) whose channels effects of the project read
|
||||
/// through [`FootageSource::aux`] (EXtractoR, Cryptomatte and the other 3D Channel effects),
|
||||
/// replacing the set given before. A source that decodes a file's picture and its channels
|
||||
/// separately can decode both at once for these from the first frame on. Sources that don't
|
||||
/// ignore it.
|
||||
fn set_aux_footage(&self, _paths: &[String]) {}
|
||||
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
|
||||
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
None
|
||||
@@ -140,6 +146,9 @@ impl FootageSource for CancellableFootage<'_> {
|
||||
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
self.inner.aux(item, footage, t)
|
||||
}
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
self.inner.set_aux_footage(paths)
|
||||
}
|
||||
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
self.inner.model(item, footage)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ pub struct RenderOpts {
|
||||
pub nested_switches: bool,
|
||||
/// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows.
|
||||
pub draft_shadows: bool,
|
||||
/// No HDR step: the top-level comp leaves the working space for the output space without
|
||||
/// Project Settings ▸ HDR (compand / tone mapping), so over-range values stay. The consumer
|
||||
/// decodes the frame with [`crate::color::Pipe::output_linear`] (float outputs: OpenEXR)
|
||||
/// or applies the step itself. Part of the viewer's frame keys (`opts_hash`).
|
||||
pub skip_hdr_step: bool,
|
||||
}
|
||||
|
||||
impl RenderOpts {
|
||||
@@ -49,6 +54,7 @@ impl Default for RenderOpts {
|
||||
backend: Backend::Cpu,
|
||||
nested_switches: true,
|
||||
draft_shadows: true,
|
||||
skip_hdr_step: false,
|
||||
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,14 +473,15 @@ impl<'a> Renderer<'a> {
|
||||
self.pipe.quantize(&mut canvas);
|
||||
}
|
||||
// The top-level comp leaves the working space for the (sRGB) display / output.
|
||||
let out = self.output_pipe();
|
||||
if self.depth == 0
|
||||
&& let Some(c) = self.pipe.output()
|
||||
&& let Some(c) = out.output()
|
||||
{
|
||||
color::convert(&mut canvas, &c);
|
||||
self.pipe.quantize(&mut canvas);
|
||||
}
|
||||
if self.depth == 0
|
||||
&& let Some((lin, mode, enc)) = self.pipe.output_hdr()
|
||||
&& let Some((lin, mode, enc)) = out.output_hdr()
|
||||
{
|
||||
color::output_hdr(&mut canvas, lin, mode, enc);
|
||||
self.pipe.quantize(&mut canvas);
|
||||
|
||||
@@ -339,3 +339,49 @@ fn float_footage_is_encoded_once_with_the_working_gamma() {
|
||||
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
|
||||
}
|
||||
}
|
||||
|
||||
/// `RenderOpts::skip_hdr_step` (float outputs) skips Project Settings ▸ HDR: its top-level frame
|
||||
/// is the Clip frame in every mode, while other renders keep compand / tone mapping.
|
||||
#[test]
|
||||
fn skip_hdr_step_frames_are_the_clip_frames() {
|
||||
use effectcraft_project::HdrMode;
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
|
||||
let (mut p, cid, _) = setup(depth);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [4.0, 2.0, 0.5], BlendMode::Normal, 100.0);
|
||||
let frame = |p: &Project, skip_hdr_step: bool| {
|
||||
Renderer::new(p, &crate::NoFootage, RenderOpts { skip_hdr_step, ..Default::default() }).comp_frame(cid, Tick::ZERO)
|
||||
};
|
||||
let clip = frame(&p, false);
|
||||
assert_eq!(clip.data, frame(&p, true).data, "{depth:?} Clip");
|
||||
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
|
||||
p.settings.hdr = mode;
|
||||
assert_eq!(frame(&p, true).data, clip.data, "{depth:?} {mode:?}");
|
||||
assert_ne!(frame(&p, false).data, clip.data, "{depth:?} {mode:?} keeps the HDR step");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Pipe::output_linear` decodes with the output space's curve (the Working Gamma's when
|
||||
/// unmanaged) and undoes `Pipe::output` exactly where the primaries match.
|
||||
#[test]
|
||||
fn output_linear_decodes_with_the_output_curve() {
|
||||
use effectcraft_color::Conversion;
|
||||
use effectcraft_project::ProjectSettings;
|
||||
let pipe = |ws: Option<ColorSpace>, out: Option<ColorSpace>, gamma: f64| {
|
||||
crate::color::Pipe::of(&ProjectSettings { working_space: ws, output_space: out, working_gamma: gamma, ..Default::default() })
|
||||
};
|
||||
assert_eq!(pipe(None, None, 2.2).output_linear(), Conversion::linearize(ColorSpace::Srgb));
|
||||
assert_eq!(pipe(None, Some(ColorSpace::DisplayP3), 2.4).output_linear(), Conversion::linearize(ColorSpace::Rec709), "unmanaged: the Working Gamma");
|
||||
assert_eq!(pipe(Some(ColorSpace::Srgb), None, 2.4).output_linear(), Conversion::linearize(ColorSpace::Srgb));
|
||||
assert_eq!(pipe(Some(ColorSpace::Srgb), Some(ColorSpace::Rec709), 2.2).output_linear(), Conversion::linearize(ColorSpace::Rec709));
|
||||
// Linear light in, linear light out: 4.0 stays 4.0 through output + output_linear.
|
||||
let p = pipe(Some(ColorSpace::Rec709), Some(ColorSpace::Srgb), 2.2);
|
||||
let p = crate::color::Pipe { linear: true, ..p };
|
||||
let enc = p.output().unwrap().apply([4.0, 1.0, 0.25]);
|
||||
let lin = p.output_linear().apply(enc);
|
||||
for (got, want) in lin.into_iter().zip([4.0, 1.0, 0.25]) {
|
||||
assert!((got - want).abs() <= 1e-5 * want, "{lin:?}");
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ impl EffectcraftApp {
|
||||
backend: effectcraft_engine::render::Backend::Auto,
|
||||
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
|
||||
draft_shadows: self.session.prefs.three_d.realtime_shadows,
|
||||
skip_hdr_step: false,
|
||||
proxy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() {
|
||||
assert_eq!(to_color_image(&image).pixels, [egui::Color32::TRANSPARENT]);
|
||||
}
|
||||
|
||||
/// Frames without the HDR step (`RenderOpts::skip_hdr_step`) never share a RAM or disk
|
||||
/// cache key with the HDR-mapped frames of the same options.
|
||||
#[test]
|
||||
fn frames_without_the_hdr_step_have_keys_of_their_own() {
|
||||
let opts = RenderOpts::default();
|
||||
assert_ne!(opts_hash(&opts), opts_hash(&RenderOpts { skip_hdr_step: true, ..opts }));
|
||||
}
|
||||
|
||||
struct ProbeDisk {
|
||||
keys: Mutex<HashSet<u128>>,
|
||||
probes: std::sync::atomic::AtomicUsize,
|
||||
|
||||
@@ -428,9 +428,18 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
|
||||
/// (#275): drag a marker out of it and it follows the pointer along the marker strip `strip`
|
||||
/// (snapping like a dragged marker); dropping it there adds a composition marker at that time,
|
||||
/// one undo step. Dropped back on the bin or outside the time graph's width, nothing is added.
|
||||
/// A double-click on the bin adds one at the current time (#1826).
|
||||
pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, bin: Rect, strip: Rect) {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(bin, egui::Id::unique("marker-bin"), Sense::drag()).on_hover_text(tr("Composition marker bin: drag a marker to the time ruler"));
|
||||
// The bin senses drags only, so a drag starts as soon as it is pressed: sensing clicks too
|
||||
// would hold that back until the pointer moved, and a drag out released within that frame
|
||||
// would add nothing. The double-click is read from the pointer instead.
|
||||
if bin_double_clicked(ui, resp.id, bin, resp.enabled() && (resp.contains_pointer() || resp.dragged() || resp.drag_stopped()))
|
||||
&& let Err(e) = app.session.execute("comp.addMarker", json!({}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.auto.add("timeline.markerBin", bin, "Composition marker bin");
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
if hot {
|
||||
@@ -455,6 +464,64 @@ pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Com
|
||||
}
|
||||
}
|
||||
|
||||
/// What the marker bin keeps of the primary button between frames: the press being held (when,
|
||||
/// where, and whether it has moved too far since to be a click), and the last click (when,
|
||||
/// where, and whether it was on the bin: both pressed and released there).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct BinClicks {
|
||||
press: Option<(f64, Pos2, bool)>,
|
||||
last: Option<(f64, Pos2, bool)>,
|
||||
}
|
||||
|
||||
/// Whether the marker bin `bin` (widget `id`; `over` when it is enabled and has the pointer, not
|
||||
/// something above it) was double-clicked this frame: two clicks in a row, both on the bin, each
|
||||
/// in a frame when it was `over`. egui's own
|
||||
/// double-click takes any click within a few points of the last for the first one, so a click on
|
||||
/// the ruler right beside the bin and then one on the bin would count. The bin pairs the clicks
|
||||
/// itself, in the order they came, by egui's rules: a click is released within `max_click_dist`
|
||||
/// of where it was pressed and within `max_click_duration`, and the second of a double-click comes
|
||||
/// within `max_double_click_delay` and `max_click_dist` of the first. A third click starts over.
|
||||
fn bin_double_clicked(ui: &egui::Ui, id: egui::Id, bin: Rect, over: bool) -> bool {
|
||||
use egui::{Event, PointerButton};
|
||||
let (dist, duration, delay) = ui.ctx().options(|o| {
|
||||
let i = &o.input_options;
|
||||
(i.max_click_dist, i.max_click_duration, i.max_double_click_delay)
|
||||
});
|
||||
let id = id.with("clicks");
|
||||
let mut s = ui.data(|d| d.get_temp::<BinClicks>(id)).unwrap_or_default();
|
||||
let double = ui.input(|i| {
|
||||
let mut double = false;
|
||||
for e in &i.events {
|
||||
match *e {
|
||||
Event::PointerMoved(p) => {
|
||||
if let Some((_, at, moved)) = &mut s.press {
|
||||
*moved |= at.distance(p) > dist;
|
||||
}
|
||||
}
|
||||
Event::PointerButton { button: PointerButton::Primary, pressed: true, pos, .. } => s.press = Some((i.time, pos, false)),
|
||||
Event::PointerButton { button: PointerButton::Primary, pressed: false, pos, .. } => {
|
||||
let Some((pressed_at, at, moved)) = s.press.take() else { continue };
|
||||
if moved || at.distance(pos) > dist || i.time - pressed_at >= duration {
|
||||
continue;
|
||||
}
|
||||
let on = over && bin.contains(at) && bin.contains(pos);
|
||||
let pair = s.last.is_some_and(|(t, p, was_on)| was_on && on && i.time - t < delay && p.distance(pos) < dist);
|
||||
if pair {
|
||||
double = true;
|
||||
s.last = None;
|
||||
} else {
|
||||
s.last = Some((i.time, pos, on));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
double
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, s));
|
||||
double
|
||||
}
|
||||
|
||||
/// Layer markers on a layer's row `r`; on a precomp layer, its comp's markers too (read-only,
|
||||
/// outlined: hover names them, a double-click opens the nested comp at the marker).
|
||||
pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) {
|
||||
|
||||
@@ -376,6 +376,122 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() {
|
||||
assert_eq!(markers(&h), vec![13.0, 20.0]);
|
||||
}
|
||||
|
||||
/// #1826: as in After Effects, a double-click on the composition marker bin adds a composition
|
||||
/// marker at the current time, one undo step; a single click adds none, and a marker dragged
|
||||
/// out of the bin still lands where it is dropped.
|
||||
#[test]
|
||||
fn double_clicking_the_marker_bin_adds_a_comp_marker_at_the_current_time() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.timeline.pps = Some(200.0);
|
||||
h.state_mut().ui.timeline.start = 10.0;
|
||||
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
|
||||
h.state_mut().session.execute("time.set", json!({"time": 21.5})).unwrap();
|
||||
h.run_steps(2);
|
||||
let markers = |h: &Harness<'_, EffectcraftApp>| -> Vec<f64> { h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect() };
|
||||
let bin = rect(&h, "timeline.markerBin");
|
||||
let ruler = rect(&h, "timeline.ruler");
|
||||
// Presses and releases in one frame each, so a pair falls inside egui's double-click time.
|
||||
let clicks = |h: &mut Harness<'_, EffectcraftApp>, n: usize| {
|
||||
h.event(Event::PointerMoved(bin.center()));
|
||||
h.step();
|
||||
for _ in 0..n {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton {
|
||||
pos: bin.center(),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
}
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
};
|
||||
clicks(&mut h, 1);
|
||||
assert!(markers(&h).is_empty(), "a single click: {:?}", markers(&h));
|
||||
let undo = h.state().session.history.undo.len();
|
||||
clicks(&mut h, 2);
|
||||
assert_eq!(markers(&h), vec![21.5], "at the current time");
|
||||
let s = &h.state().session;
|
||||
assert_eq!(s.active_comp().unwrap().markers[0].time, s.time(), "exactly the current time");
|
||||
let history = &h.state().session.history.undo;
|
||||
assert_eq!((history.len(), history.last().map(|u| u.0.as_str())), (undo + 1, Some("Add Marker")), "one undo step");
|
||||
assert_eq!(h.state().session.time().seconds(), 21.5, "the current time stays");
|
||||
// The second press in one frame and its release in the next (a frame here is 0.25 s, inside
|
||||
// egui's 0.3 s double-click time): the bin is dragged by then, and still a marker at 22 s.
|
||||
h.state_mut().session.execute("time.set", json!({"time": 22.0})).unwrap();
|
||||
h.event(Event::PointerMoved(bin.center()));
|
||||
h.step();
|
||||
for pressed in [true, false, true] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.step();
|
||||
assert!(h.ctx.dragged_id().is_some(), "the second press drags the bin");
|
||||
h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
assert_eq!(markers(&h), vec![21.5, 22.0], "a double-click released while the bin is dragged");
|
||||
// A click on the ruler right beside the bin and then one on the bin, in one frame: egui
|
||||
// takes them for a double-click (they are a few points apart), but only one was on the bin.
|
||||
let (beside, on) = (pos2(bin.min.x - 2.0, bin.center().y), pos2(bin.min.x + 2.0, bin.center().y));
|
||||
assert!(ruler.contains(beside) && !bin.contains(beside) && bin.contains(on));
|
||||
h.event(Event::PointerMoved(beside));
|
||||
h.step();
|
||||
for (pos, pressed) in [(beside, true), (beside, false), (on, true), (on, false)] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
assert_eq!(markers(&h), vec![21.5, 22.0], "one click beside the bin and one on it");
|
||||
// On the bin, beside it, on it again: the two in a row are never both on the bin.
|
||||
h.run_steps(3);
|
||||
for pos in [on, beside, on] {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
assert_eq!(markers(&h), vec![21.5, 22.0], "on, beside, on");
|
||||
// Twice pressed beside the bin and released on it, a few points away: clicks, not on the bin.
|
||||
h.run_steps(3);
|
||||
for _ in 0..2 {
|
||||
for (pos, pressed) in [(beside, true), (on, false)] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(3);
|
||||
assert_eq!(markers(&h), vec![21.5, 22.0], "pressed beside, released on");
|
||||
// Dragged out 3 s into the visible span (from 10 s at 200 px/s): a marker at 13 s too.
|
||||
drag(&mut h, bin.center(), pos2(ruler.min.x + 6.0 + 600.0, ruler.center().y));
|
||||
assert_eq!(markers(&h), vec![13.0, 21.5, 22.0]);
|
||||
}
|
||||
|
||||
/// A marker dragged out of the bin and dropped within one frame (a fast drag, or a slow frame)
|
||||
/// still lands where it is dropped: the bin starts its drag as soon as it is pressed.
|
||||
#[test]
|
||||
fn a_marker_dragged_out_of_the_bin_and_dropped_in_one_frame_is_added() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.timeline.pps = Some(200.0);
|
||||
h.state_mut().ui.timeline.start = 10.0;
|
||||
h.run_steps(2);
|
||||
let bin = rect(&h, "timeline.markerBin");
|
||||
let ruler = rect(&h, "timeline.ruler");
|
||||
let at = pos2(ruler.min.x + 6.0 + 600.0, ruler.center().y);
|
||||
h.event(Event::PointerMoved(bin.center()));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
// Both in one frame (`event` would give each a frame of its own).
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
let markers: Vec<f64> = h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect();
|
||||
assert_eq!(markers, vec![13.0]);
|
||||
}
|
||||
|
||||
/// #976: as in After Effects, Numpad * marks the selected layers at the current time; only with
|
||||
/// no layer selected does it add a composition marker.
|
||||
#[test]
|
||||
|
||||
+4
-4
@@ -328,7 +328,7 @@ Property record (`LIST tdbs`: `tdsb`, `tdsn`, `tdb4`, then `cdat` or a `LIST lis
|
||||
| keyed spatial-template flag | `tdb4` | 79 | u8 | 1 on a static colour or point effect value, 0 once it is keyed (359 / 364, 369 / 382); 0 on keyed and static sliders (362 / 363, 391 / 397); meaning **?** | 359, 362-364, 369, 382, 391, 397 | V (values) |
|
||||
| expression flags | `tdb4` | 76, 77, 120 | u8 | all three are 1 when an expression exists. 76 and 77 are also 1 on a camera's or light's computed (auto) Position, which has no expression | 060, 213, 251 | V |
|
||||
| **expression disabled** | `tdb4` | 119 | u8 | 1 = the expression is disabled; the text and bytes 76, 77, 120 stay. On the built-in view layers the byte flips by itself after the first save, so it counts only together with an expression | 060 (0), 213 (1) | V |
|
||||
| state | `tdsb` | 0 | u32 | 1 = written (non-default value, keys or expression), 3 = default state | 040, 071, 073 (1); 3 on the unwritten properties of 000 | V (1), E (3) |
|
||||
| state | `tdsb` | 0 | u32 | 1 = written (non-default value, keys or expression), 3 = default state. **A layer, camera, light, shape or text property in the default state holds After Effects' default** or a value with no effect on that layer (survey of 839 corpus and sample files: Material Options, cone 90 / 50 and falloff 500 / 500 of point lights, Position `[0, 0, -1333.33]` of ambient and environment lights, Opacity 1 and Scale `[1, 1, 1]` of cameras and lights, a polygon's Inner Radius 50, Miter Limit 4, the inert Time Remap 0), so the importer skips it. **An effect parameter's record in the default state holds the effective value** (3.7, stored value) | 040, 071, 073 (1); 3 on the unwritten properties of 000; 1200, 1400 (effects) | V (1), E (3), V (effects) |
|
||||
| static value | `cdat` | 0 | `components` x f64 | the first `components` doubles are the value | 040, 041, 043, 071 | V |
|
||||
| user name | `tdsn/Utf8` | | text | `-_0_/-` means "no custom name" | 091 (`Mask 1`), 238 (`Group 1`) | V |
|
||||
|
||||
@@ -437,9 +437,9 @@ The expression engine is project-wide (`LIST ExEn/Utf8`, `javascript-1.0`). API:
|
||||
| **float slider valid minimum, slider range** | `pard` | 104; 112 / 116 | f32; f32 x 2 | the smallest valid value (Exposure -100, Offset -2, Gamma Correction 0.1 as f32) and the range the slider covers (Exposure -4..4, Offset -0.5..0.5, Gamma 0.1..10); the slider range is repeated as `tdum` / `tduM` (f64) in each value record (391-394). @120 holds the default again as f32 (Gamma 1.0, Blurriness 25), u16 @124 the display decimals (**H**: Exposure 2, Offset 4) | 389, 391-394 | V |
|
||||
| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not | 365, 366, 383 | V |
|
||||
| popup items | `parT/pdnm/Utf8` | | text | items separated by `\|` | 077 (`Item 1\|Item 2\|Item 3`), 214 | V |
|
||||
| stored value | `sspc/tdgp/<match>-NNNN` | | `tdbs` | present only for a parameter that differs from its default; `tdsb` 1, `cdat` as in 3.4. **Absent record = the `pard` default** | see below | V |
|
||||
| stored value | `sspc/tdgp/<match>-NNNN` | | `tdbs` | present only for a parameter that differs from its default; `tdsb` 1, `cdat` as in 3.4. **Absent record = the `pard` default.** Some parameters are recorded in the default state (`tdsb` 3) and the record still holds the value After Effects uses: every one of the 25 such records in the corpus and the samples differs from its `pard` default and reads back as recorded (Lumetri Color's hidden controls High Dynamic Range, HDR Specular, the second Look, Legacy Tone And Saturation, Face Detection, Auto Color, Auto Intensity and SemanticAutoTone Progress, 1200; Cryptomatte's Selection Mode on, 1400). No effect record in the default state holds a value that does not count. The reader takes the value in either state (`EffectParam::recorded_value`, `EffectParam::value`); `EffectParam::stored_value` keeps to written records, and a record in any other state (no sample has one) is not read: the importer reports it as damaged data. That other effects record values so is H (no sample shows one) | see below; 1200, 1400 | V (1200, 1400), H (other effects) |
|
||||
|
||||
Stored values by type (all `tdsb` 1):
|
||||
Stored values by type (`tdsb` 1, or 3 as above):
|
||||
|
||||
| Type | Stored | Evidence | Conf. |
|
||||
|---|---|---|---|
|
||||
@@ -823,7 +823,7 @@ another value).
|
||||
**Masks, shapes**
|
||||
|
||||
- Masks: locked, colour, feather falloff, rotobezier, keyed expansion, a path whose last vertex has only handles, the counter after a mask is deleted, a mask on a scaled layer (whether the box stays layer-relative), an animated path with three or more keys.
|
||||
- Shape layers: open shape paths (in shape groups), stroke dashes, taper and wave with values, merge paths, repeater, offset paths and the other path operators, item blend modes and composite order, shape direction, gradient colour stops other than the default (**?**: they appear only as an `ADBE Vector Grad Colors` entry), highlight length and angle other than 0, gradient scale and rotation; why a radial gradient writes its highlight records with `tdsb` 1 and a polygon its inner radius with `tdsb` 3 (**?**).
|
||||
- Shape layers: open shape paths (in shape groups), stroke dashes, taper and wave with values, merge paths, repeater, offset paths and the other path operators, item blend modes and composite order, shape direction, gradient colour stops other than the default (**?**: they appear only as an `ADBE Vector Grad Colors` entry), highlight length and angle other than 0, gradient scale and rotation; why a radial gradient writes its highlight records with `tdsb` 1 and a polygon its inner radius with `tdsb` 3 (**?**; the polygon's records hold the defaults 50 and 0, as every non-effect record in the default state does: 3.4, state).
|
||||
|
||||
**Text**
|
||||
|
||||
|
||||
@@ -409,7 +409,12 @@ inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; m
|
||||
from the published primaries), footage is converted from its colour profile (stream metadata or
|
||||
Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize
|
||||
Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for
|
||||
blending. Layer cache keys include these settings.
|
||||
blending. Layer cache keys include these settings. Project Settings ▸ HDR (compand / tone
|
||||
mapping) is the last top-level step, for standard-dynamic-range displays and outputs; OpenEXR
|
||||
renders (sequences and ProEXR) skip it (`RenderOpts::skip_hdr_step`) and decode the frame with the
|
||||
output space's own curve (`Pipe::output_linear`), so they hold linear light in the output space's
|
||||
primaries; unmanaged projects decode with the Working Gamma's curve (sRGB at 2.2, a pure 2.4 power
|
||||
curve at 2.4). Unmanaged projects and Rec. 2100 PQ / HLG outputs never get the HDR step.
|
||||
|
||||
**Frame blending**: footage and precomp layers whose source time falls between source frames
|
||||
(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by
|
||||
|
||||
@@ -26,7 +26,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
|
||||
|
||||
| Category | Effects | On the GPU | Known differences or bugs |
|
||||
|---|---:|---:|---|
|
||||
| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) |
|
||||
| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR: one decode per frame for the picture and the channels, cache softly bounded at 1 GB (#474; holding channels as f16 is still to do) |
|
||||
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
|
||||
| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
|
||||
| Channel | 13 | 13 | — |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** `.aep` / `.aepx` import (`crates/aep`: `container`, `import`) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-05 · **Change:** ProRes alpha final-run conformance; previously `.aep` / `.aepx` import (`crates/aep`: `container`, `import`) · **Target:** Adobe After Effects 2026
|
||||
|
||||
Every format After Effects reads or writes, with EffectCraft's support. After Effects' list comes
|
||||
from its installed bundle (26.5.0.89: `Info.plist` `CFBundleDocumentTypes`, `Plug-ins/Format`,
|
||||
@@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
| PNG | R/W | yes | yes (sequence) | good |
|
||||
| JPEG | R/W | yes | yes (sequence) | good |
|
||||
| TIFF | R/W | yes | yes (sequence) | good |
|
||||
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) |
|
||||
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
|
||||
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
|
||||
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
|
||||
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |
|
||||
@@ -62,7 +62,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
| MP4 / MOV with H.264 | R/W | yes | yes (own encoder, + AAC) | decoder bit-exact on conformance streams (FilmCraft) |
|
||||
| HEVC (H.265) | R/W | yes | yes (MP4 `hvc1`, Main / Main 10) | encoder bit-exact with ffmpeg's decode; no B-frames / SAO, larger files |
|
||||
| AV1 | R | yes | yes (MP4 `av01`, WebM) | beyond After Effects' export; no B-frames / CDEF |
|
||||
| Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW |
|
||||
| Apple ProRes (422 family, 4444) | R/W | yes | yes | alpha final-run syntax checked in exported MOVs (#293); 4444 colour tag wrong (#340); no ProRes RAW |
|
||||
| WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects |
|
||||
| MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output |
|
||||
| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output |
|
||||
@@ -146,6 +146,7 @@ relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel wit
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | ProRes encoder pin and export regression for final alpha runs; native macOS decode validated, Resolve still unverified (#293) |
|
||||
| 2026-10-30 | minor | Render-queue source-protection behavior and limits consolidated here from the standalone page |
|
||||
| 2026-10-12 | minor | #324 and #514 marked closed; open `.aep` PRs #251 and #860 noted |
|
||||
| 2026-10-10 | major | First file format parity document, from the After Effects 26.5 bundle and this repository's import/export registries |
|
||||
@@ -120,6 +120,15 @@ Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image,
|
||||
Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals,
|
||||
vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel.
|
||||
|
||||
Footage on its own keeps only the channels of its picture (a single-part file still
|
||||
decompresses all of its pixels to read them). Once an effect reads an item's channels
|
||||
(EXtractoR, Cryptomatte, the 3D Channel effects), each frame, the first included, is decoded
|
||||
once for the picture and the effects together, and kept for a while: about 1 GB of decoded
|
||||
channels (512 MB in the browser), a soft bound, as the newest file and files still in use
|
||||
stay whatever their size. Several frames asking for the same file at once wait for one
|
||||
decode. Once no enabled effect reads the item any more, its frames go back to the picture's
|
||||
channels alone.
|
||||
|
||||
The Cryptomatte effect keys objects by their Cryptomatte ID passes, with the names from the
|
||||
file's manifest. Pick them with the eyedropper next to its Selection, then click in the
|
||||
Composition viewer (Shift adds, Alt or Ctrl removes). Matte Only shows the matte as grey in
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
|
||||
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
|
||||
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (one decode per frame for picture and channels, byte-bounded cache; f16 storage still to do). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
|
||||
| 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
|
||||
Reference in new issue
Block a user