diff --git a/Cargo.lock b/Cargo.lock index a48f2d44b..629fc65e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1633,18 +1633,20 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "filmcraft-aac", "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", "filmcraft-isobmff", + "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpegts", "filmcraft-time", "gif", "image", "log", "rayon", "serde_json", - "symphonia", "thiserror 2.0.21", ] @@ -2246,8 +2248,8 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2255,40 +2257,57 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] -name = "filmcraft-av1" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +name = "filmcraft-apv" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-av1" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + +[[package]] +name = "filmcraft-avi" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" + [[package]] name = "filmcraft-bitstream" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-cfb" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-codecs" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-aac", "filmcraft-ac3", + "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -2298,6 +2317,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2305,9 +2325,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2315,16 +2337,25 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2333,8 +2364,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2344,16 +2375,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2362,8 +2393,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2372,8 +2403,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2382,8 +2413,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2399,8 +2430,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2408,15 +2439,16 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-media" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", @@ -2425,10 +2457,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2436,31 +2477,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-ogg" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-opus" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-project" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2473,8 +2514,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2483,16 +2524,24 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-vp8" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index dee46306d..c101c489c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,29 +77,29 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +# container headers read for AAC channel layouts (crates/media) +filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned -# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. -symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } @@ -131,6 +131,8 @@ sys-locale = "0.3" # filmcraft-prores = { path = "../filmcraft/crates/prores" } # filmcraft-aac = { path = "../filmcraft/crates/aac" } # filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" } +# filmcraft-matroska = { path = "../filmcraft/crates/matroska" } +# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index 1ce9d9bb2..740ec6010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | +| Spanish | menus only | 29% | | Hindi | none | 0% | | Arabic | none | 0% | | French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 34% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | +| Japanese | partial (menus, most of the panels) | 46% | | German | menus only | 16% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had native-speaker review. ## Upcoming @@ -117,13 +117,33 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel, + Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items + drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one + animation preset can hold several (#2552). The Composition panel pans while a mask vertex is + held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget + (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick + between frames (#2593). ScriptUI drags keep their control past the window edge with + `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or + `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and + rotation of the paths above it by random amounts that change over time, up to the Transform + values it holds. The copies of a Repeater above it wiggle independently as far as its + Correlation allows; with the Repeater below it, they wiggle together. +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774: + Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese + panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is + 46% with its 307 effect names; the overall share is ≈ 24%. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all and its vertices could not be edited or animated (#932). +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel @@ -138,7 +158,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. -- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. @@ -158,11 +177,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size | +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | -| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | -| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 5aedfad58..db6394b21 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1296,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's // `startup` says (default: the last visit's project, the demo on a first visit). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 452196224..bdbdcda94 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -263,6 +263,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -495,6 +498,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/contributors/people.toml b/contributors/people.toml index 8654c7a10..59365207a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,11 +17,19 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.Krueels] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.lewandowskit] +real_name = "Tomasz Lewandowski" +display_name = "Tomasz Lewandowski" +sync_github_name = false + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 67e5aef98..06b83a880 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -30,10 +30,9 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", @@ -97,6 +96,7 @@ const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", /// Commands that write files named by these parameters. const WRITES: &[(&str, &[&str])] = &[ + ("camera.exportBlender", &["path"]), ("file.save", &["path"]), ("file.saveAs", &["path"]), ("file.saveCopy", &["path"]), @@ -167,7 +167,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } // Scripts can't grant themselves the access the user withheld @@ -397,6 +397,7 @@ mod tests { let classified = keys_of(READS, id).is_some() || keys_of(WRITES, id).is_some() || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) || IMPLICIT_WRITES.contains(&id) || id.starts_with("renderQueue.") || id == "render.addOutputModule" diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..1121c5d91 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); @@ -1473,3 +1485,30 @@ fn automation_roots_refuse_running_script_code() { call_json(&mut s, "execute_command", json!({"command": "scriptui.list"})); let _ = std::fs::remove_dir_all(&base); } + +#[test] +fn blender_export_requires_an_absolute_path_in_a_write_root() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-blender-confine-{}", std::process::id())); + let (read, write) = (base.join("read"), base.join("write")); + for dir in [&read, &write] { + std::fs::create_dir_all(dir).unwrap(); + } + let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path})); + assert_eq!(check(&write.join("scene.py")).unwrap(), None); + for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] { + assert!(check(&path).is_err(), "must deny {}", path.display()); + } + for path in ["scene.py", "./scene.py", "write/scene.py", "../scene.py"] { + let error = check(std::path::Path::new(path)).unwrap_err().to_string(); + assert!(error.contains("give an absolute path"), "{path}: {error}"); + } + let read_only = Confinement::new(std::slice::from_ref(&write), &[]).unwrap(); + assert!(read_only.check_command("camera.exportBlender", &json!({"path": write.join("scene.py")})).is_err()); + for path in [json!(read.join("scene.py")), json!("scene.py"), json!("$1.path")] { + let batch = json!({"steps": [{"command": "camera.exportBlender", "params": {"path": path}}]}); + assert!(confinement.check_command("engine.batch", &batch).is_err()); + } + std::fs::remove_dir_all(&base).unwrap(); +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index db413544b..7add5fc58 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -95,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 117506807..c865d4f96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; -pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, @@ -312,6 +312,19 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + self.pad_sides([left, top, 0, 0]); + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as /// clamped by [`Image::effective_pad`]. pub fn pad(&mut self, pad: u32) { @@ -377,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 Some((dx as i64, dy as i64)) } +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -555,6 +595,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1583,4 +1632,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index e1f41954c..15d5d0aac 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let (sx, sy) = (s * kx, s * ky); // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). @@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { let reach = if frac > 0.0 { r + 1 } else { r }; b.pad((reach * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let edge = repeat || ctx.adjustment; let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 79a225f75..333549e7d 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -38,7 +38,7 @@ use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; pub use registry::{specs, sprite_plan}; pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; pub(crate) use sprites::{combine, raster, splat}; -pub use weather::{Bubble, bubble_list, drizzle_drops}; +pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops}; #[cfg(test)] mod tests; diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index e368c7bbe..06ce973b2 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -97,6 +97,60 @@ fn bubbles_rise() { assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); } +/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands +/// in for transparent ones. +#[test] +fn bubbles_near_edges_show_the_layer() { + let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]); + let pale = [0, 1].map(|reflection| { + let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))]; + let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + // Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha. + a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count() + }); + assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); +} + +/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels +/// (a half-resolution preview's layout). +fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image { + let size = 40.0; + let s = crate::find("ec.sim.ccbubbles").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() }; + for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] { + params.values.insert(k.into(), v); + } + let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() }; + let mut b = Buf { img, offset: [0.0; 2], scale }; + b.pad(6); + crate::apply(s, &ctx, b).img +} + +/// #2024: the same when the buffer has transparent padding around the layer: samples past the +/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is +/// still refracted as the pale colour. +#[test] +fn bubbles_near_padded_edges_show_the_layer() { + let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]); + let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count(); + for (scale, side) in [(1.0, 40), (0.5, 20)] { + let counts = [0, 1].map(|reflection| { + let a = padded_bubbles(blue(side), scale, reflection); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + pale(&a) + }); + assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)"); + } + let mut holed = blue(40); + for y in 12..28 { + for x in 12..28 { + holed.set(x, y, [0.0; 4]); + } + } + assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour"); +} + #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index b93c0e48b..347ab4939 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,7 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; +use crate::util::layer_rect_at; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall @@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { .collect() } +/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the +/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat +/// the layer's edge pixels instead of reaching transparent padding around it (#2024). +pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] { + let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale); + let (x0, y0) = (lx + 0.5, ly + 0.5); + [x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32] +} + pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let metal = pr.e("reflectionType") == 1; let shading = pr.e("shadingType"); let list = bubble_list(ctx, &b); + let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). let k = if metal { 1.6 } else { -0.7 }; let nz = (1.0 - d2).max(0.0).sqrt(); - let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1)); + let p = src.sample_bilinear_clamped(sx as f64, sy as f64); let (c, sa) = unpremul(p); let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; let mut a = cov; diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 2a3a7c724..388790901 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43), ("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22), ("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d), - ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae), - ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed), - ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d), - ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533), - ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2), - ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27), - ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce), - ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c), - ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), - ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), - ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), - ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), - ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067), - ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e), - ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc), - ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479), - ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641), - ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f), - ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284), - ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e), + ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4), + ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3), + ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156), + ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90), + ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a), + ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), + ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), + ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), + ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43), + ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e), + ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2), + ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715), + ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), + ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), + ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), + ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802), + ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d), + ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a), + ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618), + ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe), ("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5), ("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3), ("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557), diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index fe04f882b..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..bd089b52b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -10,6 +10,7 @@ mod animation; pub(crate) mod app_more; mod autotrace; mod batch; +mod blender; mod camera_cmds; mod comp; pub(crate) mod comp_more; diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index 5bd897ba9..29597a3bc 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result { let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| is_user(l)) { + for l in c.layers.iter_mut_where(|l| is_user(l)) { l.source = LayerSource::Comp { item: comp }; n += 1; } diff --git a/crates/engine/src/cmd/batch.rs b/crates/engine/src/cmd/batch.rs index f6e6901cc..5931fe09d 100644 --- a/crates/engine/src/cmd/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/cmd/blender.rs b/crates/engine/src/cmd/blender.rs new file mode 100644 index 000000000..e6c678101 --- /dev/null +++ b/crates/engine/src/cmd/blender.rs @@ -0,0 +1,295 @@ +//! Camera-solve interchange through Blender's public Python API. The generated script is +//! original MIT OR Apache-2.0 code; no Blender implementation is read or embedded. + +use std::path::Path; + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Uid}; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; +use serde_json::{Value, json}; + +mod validate; + +use super::{b_p, bad, f_p, str_p}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "camera.exportBlender"; +const MAX_FRAMES: usize = 100_000; +const MAX_POINTS: usize = 100_000; +const MAX_JSON: usize = 64 * 1024 * 1024; +const MAX_FRAME: i64 = 1_048_574; + +fn number(p: &Value, key: &str, default: f64, min: f64, max: f64) -> Result { + let v = match p.get(key) { + None => default, + Some(_) => f_p(p, key).ok_or_else(|| bad(CMD, format!("{key}: expected a number")))?, + }; + if !v.is_finite() || !(min..=max).contains(&v) { + return Err(bad(CMD, format!("{key}: expected {min}..{max}"))); + } + Ok(v) +} + +fn finite(values: impl IntoIterator) -> Result<()> { + if values.into_iter().any(|v| !v.is_finite() || v.abs() > 1e12) { + return Err(bad(CMD, "invalid or excessive numeric camera/scene data; re-analyze the tracker")); + } + Ok(()) +} + +/// Right-handed EffectCraft world (right, down, forward) → Blender (right, forward, up). +fn position(p: [f64; 3], scale: f64) -> [f64; 3] { + [p[0] * scale, p[2] * scale, -p[1] * scale] +} + +fn media_path(s: &Session, path: &str) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(bad(CMD, "the source footage has no valid file path; relink it before exporting")); + } + let path = Path::new(path); + if path.is_absolute() || cfg!(target_arch = "wasm32") { + return Ok(path.to_string_lossy().into_owned()); + } + let cwd = std::env::current_dir().map_err(|e| bad(CMD, format!("cannot resolve footage path: {e}")))?; + let base = s.path.as_deref().and_then(|p| Path::new(p).parent()).map(|p| cwd.join(p)).unwrap_or(cwd); + Ok(base.join(path).to_string_lossy().into_owned()) +} + +pub(super) fn export(s: &mut Session, p: &Value, cid: ItemId, lid: LayerId, uid: Uid) -> Result { + if s.running_for_script() && !s.prefs.scripting.allow_scripts_write_files { + return Err(bad(CMD, "scripts can't export files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on")); + } + let options = Options::parse(p)?; + let Options { path, sensor, units, start, include_points } = options; + if s.camera_target().is_some_and(|(c, l, u)| (c, l, u) == (cid, lid, uid)) && s.is_camera_analyzing() || s.camera_pending.contains(&(cid, lid, uid)) { + return Err(bad(CMD, "wait for this tracker's analysis to finish before exporting")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or_else(|| bad(CMD, "tracked layer missing"))?; + let g = layer.props.find_group(uid).ok_or_else(|| bad(CMD, "tracker missing"))?; + for name in [ct::SOLVE, ct::TRACKS] { + if g.prop(name).is_some_and(|p| matches!(&p.value, effectcraft_keyframe::Value::Str(v) if v.len() > MAX_JSON)) { + return Err(bad(CMD, "tracking data exceeds the 64 MiB export limit")); + } + } + let params = crate::camera_track::static_params(g); + let solve = ct::solve(¶ms).ok_or_else(|| bad(CMD, "the camera is not solved yet; analyze the tracker first"))?; + let (lo, end) = validate::timing(comp, layer, &solve, start)?; + let Source { footage, path: source_path, fps: source_fps, count: source_count } = source(s, layer, &solve, path)?; + validate::geometry(&solve, sensor)?; + let camera = params + .get(ct::CAMERA) + .map(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0 && *v <= u64::MAX as f64) + .and_then(|id| comp.layer(LayerId(id as u64))) + .filter(|l| l.is_camera()); + let warnings = scene_warnings(camera, &solve, footage); + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, lo) }; + let (frames, world) = bake_frames(&ctx, layer, &solve, camera, &options, footage, source_count)?; + let mut points = Vec::new(); + for point in &solve.points { + finite(point.pos.into_iter().chain([point.error as f64]))?; + if point.first > point.last || point.last as usize >= solve.frames.len() { + return Err(bad(CMD, "invalid solved point frame range; re-analyze the tracker")); + } + let location = position(world.apply(point.pos), units); + finite(location)?; + if include_points { + points.push(json!({"id": point.id, "location": location, "error": point.error, + "first": start.saturating_add(point.first as i64), "last": start.saturating_add(point.last as i64)})); + } + } + let tracks = ct::tracks(¶ms); + if tracks.as_ref().is_some_and(|t| t.tracks.len() > MAX_POINTS || t.tracks.iter().any(|t| t.pts.len() > MAX_FRAMES)) { + return Err(bad(CMD, "2D tracks exceed export limits")); + } + let markers: Vec = comp + .markers + .iter() + .filter_map(|m| { + let frame = start.saturating_add(comp.frame_rate.frame_at(m.time).saturating_sub(comp.frame_rate.frame_at(lo))); + if (start..=end).contains(&frame) { Some(json!({"name": m.comment, "frame": frame})) } else { None } + }) + .collect(); + let payload = json!({"version": 1, "name": format!("{} — {}", s.project.item(cid).map(|i| i.name.as_str()).unwrap_or("Shot"), layer.name), + "resolution": solve.size, "fpsNumerator": comp.frame_rate.num, "fpsDenominator": comp.frame_rate.den, + "startFrame": start, "endFrame": end, "sensorWidth": sensor, "unitScale": units, + "media": {"path": source_path, "kind": if footage.kind == FootageKind::Still {"FILE"} else {"MOVIE"}, + "fps": source_fps, "nativeRate": footage.native_rate, "pixelAspect": footage.pixel_aspect, "timecode": footage.start_timecode, + "frameCount": source_count, "loopCount": footage.loop_count}, + "frames": frames, "points": points, "markers": markers, "ground": solve.ground.is_some(), + "groundSize": world.s * units, "shutter": comp.shutter_angle / 360.0, "shutterPhase": comp.shutter_phase, + "solve": solve, "tracks": if include_points { tracks.as_deref() } else { None }, + "warnings": warnings}); + let data = serde_json::to_string(&payload).map_err(|e| bad(CMD, format!("cannot serialize scene: {e}")))?; + if data.len() > MAX_JSON { + return Err(bad(CMD, "scene exceeds the 64 MiB script limit; disable includePoints or split the shot")); + } + let quoted = serde_json::to_string(&data).map_err(|e| bad(CMD, format!("cannot encode scene: {e}")))?; + let script = include_str!("blender_scene.py").replace("__EFFECTCRAFT_DATA__", "ed); + s.services.write_file(path, script.as_bytes()).map_err(|e| bad(CMD, format!("cannot write {path}: {e}")))?; + Ok(json!({"path": path, "bytes": script.len(), "frames": frames.len(), "points": points.len(), "warnings": warnings})) +} + +struct Options<'a> { + path: &'a str, + sensor: f64, + units: f64, + start: i64, + include_points: bool, +} + +impl<'a> Options<'a> { + fn parse(p: &'a Value) -> Result { + let path = str_p(p, "path").filter(|p| !p.is_empty() && !p.contains('\0')).ok_or_else(|| bad(CMD, "missing `path` (.py)"))?; + if !Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("py")) { + return Err(bad(CMD, "the export path must end in .py")); + } + let sensor = number(p, "sensorWidth", 36.0, 1.0, 1000.0)?; + let units = number(p, "unitScale", 0.01, 0.000_001, 1000.0)?; + let start = match p.get("startFrame") { + None => 1, + Some(v) => v.as_i64().filter(|n| (1..=MAX_FRAME).contains(n)).ok_or_else(|| bad(CMD, "startFrame: expected a positive Blender frame number"))?, + }; + let include_points = match p.get("includePoints") { + Some(v) if !v.is_boolean() => return Err(bad(CMD, "includePoints: expected a boolean")), + _ => b_p(p, "includePoints").unwrap_or(true), + }; + Ok(Self { path, sensor, units, start, include_points }) + } +} + +struct Source<'a> { + footage: &'a Footage, + path: String, + fps: f64, + count: i64, +} + +fn source<'a>(s: &'a Session, layer: &Layer, solve: &CameraSolve, path: &str) -> Result> { + let footage = match layer.source { + LayerSource::Footage { item } => s.project.item(item).and_then(|i| match &i.kind { + ItemKind::Footage(f) => Some(f), + _ => None, + }), + _ => None, + } + .filter(|f| f.has_video && matches!(f.kind, FootageKind::Video | FootageKind::Still)); + let footage = footage + .ok_or_else(|| bad(CMD, "Blender background export needs a video or still-footage layer; render precomps/sequences to a video and track that video"))?; + let source_path = media_path(s, &footage.path)?; + if Path::new(path) == Path::new(&source_path) { + return Err(bad(CMD, "the export path must differ from the source footage")); + } + if footage.width as f64 != solve.size[0] || footage.height as f64 != solve.size[1] { + return Err(bad(CMD, "the analysis image differs from the original footage dimensions; render the tracker's input to a video and track that video")); + } + let source_fps = footage.frame_rate.as_f64(); + if footage.frame_rate.num <= 0 || footage.frame_rate.den <= 0 || !source_fps.is_finite() || source_fps <= 0.0 { + return Err(bad(CMD, "invalid source footage frame rate")); + } + // Same frame selection as the media pool: interpreted FPS, rounded per-loop frame count, + // clamp to the total loop range, then wrap. Native FPS does not change the frame index. + let divisor = (effectcraft_time::TICKS_PER_SECOND as i128).saturating_mul(footage.frame_rate.den as i128); + let source_count = (footage.duration.0 as i128).saturating_mul(footage.frame_rate.num as i128).saturating_add(divisor / 2) / divisor; + let source_count = if footage.kind == FootageKind::Still { 1 } else { source_count }; + if !(1..=MAX_FRAME as i128).contains(&source_count) { + return Err(bad(CMD, "invalid footage duration or footage exceeds Blender's frame range; trim/render the source video first")); + } + let source_count = source_count as i64; + Ok(Source { footage, path: source_path, fps: source_fps, count: source_count }) +} + +fn scene_warnings(camera: Option<&Layer>, solve: &CameraSolve, footage: &Footage) -> Vec { + let mut warnings = vec![ + "Sensor width is assumed (36 mm by default); monocular tracking cannot recover physical sensor size or absolute scene scale.".to_string(), + "The original footage is referenced, not embedded or rendered: masks and effects before the tracker are not baked. Relink the video if moving this script.".to_string(), + ]; + if camera.is_none() { + warnings + .push("No linked tracker camera: focus uses the default scene depth, DOF is off; optical focus cannot be recovered from feature tracks.".into()); + } else { + warnings.push("Focus/iris settings are sampled from the linked camera; pose and lens come from the tracker solve. Blender's physical DOF is an approximation of EffectCraft's blur model.".into()); + } + if solve.distortion.is_some_and(|d| !d.is_none()) { + warnings.push("Radial distortion is preserved as metadata (Brown k1/k2, half-diagonal normalization), not applied to Blender's pinhole camera. Use an undistorted plate for exact alignment.".into()); + } + if (footage.pixel_aspect - 1.0).abs() > 1e-6 { + warnings.push("The tracker solves square pixels: export uses square pixels; original footage pixel aspect is retained in metadata.".into()); + } + warnings +} + +/// Bake the solved camera and the linked camera's optical settings at exact composition ticks. +fn bake_frames( + ctx0: &EvalCtx<'_>, + layer: &Layer, + solve: &CameraSolve, + camera: Option<&Layer>, + options: &Options<'_>, + footage: &Footage, + source_count: i64, +) -> Result<(Vec, linalg::Similarity)> { + let comp = ctx0.comp; + let lo = ctx0.time; + let fd = comp.frame_duration(); + let sensor = options.sensor; + let units = options.units; + let start = options.start; + let first = solve.frames.first().ok_or_else(|| bad(CMD, "the camera solve is empty"))?; + let m = ctx0.world_matrix(layer).0; + let layer_scale = m[0][0].hypot(m[1][0]); + finite([layer_scale])?; + if layer_scale <= 1e-9 { + return Err(bad(CMD, "the tracked layer has zero scale")); + } + let world = solve.world([comp.width as f64, comp.height as f64], first.focal * layer_scale); + finite([world.s * units])?; + let mut frames = Vec::with_capacity(solve.frames.len()); + for (i, frame) in solve.frames.iter().enumerate() { + let time = Tick(lo.0.saturating_add(fd.0.saturating_mul(i as i64))); + let ctx = ctx0.at(time); + let pose = linalg::mm(&world.r, &linalg::mt(&frame.r())); + let right = position(linalg::mv(&pose, [1.0, 0.0, 0.0]), 1.0); + let up = position(linalg::mv(&pose, [0.0, -1.0, 0.0]), 1.0); + let back = position(linalg::mv(&pose, [0.0, 0.0, -1.0]), 1.0); + let location = position(world.apply(frame.center), units); + let lens = frame.focal * sensor / solve.size[0]; + let opts = camera.and_then(|l| l.props.sub("cameraOptions").map(|g| (l, g))); + let value = |key: &str, fallback: f64| opts.map(|(l, g)| ctx.f(l, g, key, fallback)).unwrap_or(fallback); + let focus = value("focusDistance", world.s) * units; + let aperture = value("aperture", 0.0); + let zoom = value("zoom", frame.focal * layer_scale); + // Match Camera Settings' f-stop conversion: focal_for_zoom uses a 36 mm film width, + // and the aperture UI is mm scaled by comp_width / 1920 (not a world-space diameter). + let aperture_mm = aperture * 1920.0 / comp.width as f64; + let fstop = if aperture_mm > 0.0 { (zoom * 36.0 / comp.width as f64) / aperture_mm } else { 2.8 }; + let rotation = value("irisRotation", 0.0).to_radians(); + let ratio = value("irisAspectRatio", 1.0); + let shape = opts.map(|(l, g)| ctx.e(l, g, "irisShape")).unwrap_or(0); + let blades = match shape { + 0 => 4, + 1..=8 => shape.saturating_add(2), + _ => return Err(bad(CMD, "invalid iris shape")), + }; + let dof = opts.is_some_and(|(l, g)| ctx.b(l, g, "dof")) && aperture > 0.0 && value("blurLevel", 100.0) > 0.0; + let source_time = ctx.source_time(layer); + let source_frame = if footage.kind == FootageKind::Still { + 0 + } else { + footage.frame_rate.frame_at(source_time).clamp(0, source_count.saturating_mul(footage.loop_count.max(1) as i64).saturating_sub(1)) % source_count + }; + finite(location.into_iter().chain([focus, aperture, zoom, fstop, rotation, ratio, value("blurLevel", 100.0), value("irisRoundness", 0.0)]))?; + if focus <= 0.0 || !(0.01..=100.0).contains(&ratio) || !(0.01..=1e6).contains(&fstop) { + return Err(bad(CMD, "focus, f-stop or iris aspect is outside Blender's supported range")); + } + frames.push(json!({"frame": start + i as i64, "timeTicks": time.0, "sourceFrame": source_frame, + "location": location, "rotation": [right, up, back], "lens": lens, "focus": focus, "dof": dof, + "fstop": fstop, "blades": blades, "irisRotation": rotation, "irisRatio": ratio, + "sourceAperture": aperture, "sourceZoom": zoom, + "blurLevel": value("blurLevel", 100.0), "irisRoundness": value("irisRoundness", 0.0)})); + } + Ok((frames, world)) +} diff --git a/crates/engine/src/cmd/blender/validate.rs b/crates/engine/src/cmd/blender/validate.rs new file mode 100644 index 000000000..a9454861f --- /dev/null +++ b/crates/engine/src/cmd/blender/validate.rs @@ -0,0 +1,71 @@ +//! Validate solved geometry and its correspondence to the current layer timing. + +use effectcraft_project::{Comp, Layer}; +use effectcraft_time::Tick; +use effectcraft_track::camtrack::{CameraSolve, linalg}; + +use super::{CMD, MAX_FRAME, MAX_FRAMES, MAX_POINTS, bad, finite}; +use crate::Result; + +pub(super) fn timing(comp: &Comp, layer: &Layer, solve: &CameraSolve, start: i64) -> Result<(Tick, i64)> { + if solve.frames.len() > MAX_FRAMES || solve.points.len() > MAX_POINTS { + return Err(bad(CMD, "export is limited to 100,000 frames and 100,000 points; split the shot")); + } + if solve.size.iter().any(|v| !v.is_finite() || *v < 4.0 || *v > 65_536.0 || v.fract() != 0.0) { + return Err(bad(CMD, "invalid tracking resolution (expected 4..65536 whole pixels)")); + } + let fps = comp.frame_rate.as_f64(); + if comp.frame_rate.num <= 0 || comp.frame_rate.den <= 0 || !fps.is_finite() || !(1.0..=1000.0).contains(&fps) { + return Err(bad(CMD, "invalid composition frame rate (expected 1..1000 fps)")); + } + let end = start + .checked_add(solve.frames.len() as i64) + .and_then(|n| n.checked_sub(1)) + .filter(|n| *n <= MAX_FRAME) + .ok_or_else(|| bad(CMD, "the baked animation exceeds Blender's frame range"))?; + finite([solve.start, solve.frame_duration, solve.average_error, layer.stretch, comp.shutter_angle, comp.shutter_phase])?; + if layer.stretch == 0.0 || comp.width == 0 || comp.height == 0 { + return Err(bad(CMD, "invalid composition dimensions or zero layer stretch")); + } + let lo = comp.frame_rate.snap_nearest(layer.in_point.max(Tick::ZERO)); + let hi = layer.out_point.min(comp.duration); + let fd = comp.frame_duration(); + if fd.0 <= 0 || lo >= hi { + return Err(bad(CMD, "the tracked layer has no active frames")); + } + let expected = ((hi.0 as i128 - lo.0 as i128 + fd.0 as i128 - 1) / fd.0 as i128) as u128; + if expected != solve.frames.len() as u128 { + return Err(bad(CMD, "the solve no longer matches the layer's frame range; re-analyze the tracker")); + } + let dt = (layer.layer_time(lo + fd) - layer.layer_time(lo)).seconds(); + if (solve.start - layer.layer_time(lo).seconds()).abs() > 1e-6 || solve.frames.len() > 1 && (solve.frame_duration - dt).abs() > 1e-6 { + return Err(bad(CMD, "the solve no longer matches the layer's timing; re-analyze the tracker")); + } + Ok((lo, end)) +} + +pub(super) fn geometry(solve: &CameraSolve, sensor: f64) -> Result<()> { + for frame in &solve.frames { + finite(frame.rot.into_iter().chain(frame.center).chain([frame.focal]))?; + if !frame.solved || frame.focal <= 0.0 { + return Err(bad(CMD, "the solve contains unsolved frames or invalid focal lengths; re-analyze the tracker")); + } + let mm = sensor * frame.focal / solve.size[0]; + if !(1.0..=5000.0).contains(&mm) { + return Err(bad(CMD, "lens outside Blender's 1..5000 mm range; choose another sensorWidth")); + } + } + if let Some(ground) = &solve.ground { + finite(ground.origin.into_iter().chain(ground.normal).chain(ground.x_axis))?; + if (linalg::norm(ground.normal) - 1.0).abs() > 1e-4 + || (linalg::norm(ground.x_axis) - 1.0).abs() > 1e-4 + || linalg::dot(ground.normal, ground.x_axis).abs() > 1e-4 + { + return Err(bad(CMD, "invalid ground-plane basis; set the ground plane again")); + } + } + if let Some(d) = &solve.distortion { + finite([d.k1, d.k2, d.radius])?; + } + Ok(()) +} diff --git a/crates/engine/src/cmd/blender_scene.py b/crates/engine/src/cmd/blender_scene.py new file mode 100644 index 000000000..0a9700a00 --- /dev/null +++ b/crates/engine/src/cmd/blender_scene.py @@ -0,0 +1,204 @@ +# EffectCraft camera tracker → Blender, original code under MIT OR Apache-2.0. +# Run in Blender's Scripting workspace (Open, Run Script), or: +# blender --background --python "Tracked Scene.py" +# Creates a NEW scene; existing scenes and objects are preserved. Save the .blend yourself. +# Public API types: bpy.types.Camera, bpy.types.ImageUser +import json +import math +import os + +import bpy +from mathutils import Matrix + +DATA = json.loads(__EFFECTCRAFT_DATA__) + + +def _curves(owner): + """Blender 4.0–4.3 legacy actions and 4.4+/5.x layered actions.""" + anim = owner.animation_data + if anim is None or anim.action is None: + return + action = anim.action + if getattr(action, "is_action_layered", False): + for layer in action.layers: + for strip in layer.strips: + if strip.type == 'KEYFRAME': + bag = strip.channelbag(anim.action_slot) + if bag is not None: + yield from bag.fcurves + else: + yield from action.fcurves + + +def _interpolation(owner): + for curve in _curves(owner): + # Footage indices and discrete switches must never interpolate between frames. + stepped = ("frame_offset" in curve.data_path or "use_dof" in curve.data_path + or "aperture_blades" in curve.data_path) + for key in curve.keyframe_points: + key.interpolation = 'CONSTANT' if stepped else 'LINEAR' + + +def _media_path(): + original = DATA["media"]["path"] + candidates = [original] + # When sharing, place the video alongside the script or the .blend and run again. + script_path = globals().get("__file__", "") + if script_path: + candidates.append(os.path.join(os.path.dirname(os.path.abspath(script_path)), + os.path.basename(original))) + if bpy.data.filepath: + candidates.append(os.path.join(os.path.dirname(bpy.data.filepath), + os.path.basename(original))) + return next((p for p in candidates if os.path.isfile(p)), None) + + +def import_scene(): + if bpy.app.version < (4, 0, 0): + raise RuntimeError("EffectCraft scene import requires Blender 4.0 or newer") + scene = bpy.data.scenes.new(DATA["name"]) + scene.frame_start = DATA["startFrame"] + scene.frame_end = DATA["endFrame"] + fps = DATA["fpsNumerator"] / DATA["fpsDenominator"] + scene.render.fps = max(1, round(fps)) + scene.render.fps_base = scene.render.fps / fps + scene.render.resolution_x, scene.render.resolution_y = map(int, DATA["resolution"]) + scene.render.resolution_percentage = 100 + # The tracker assumes square pixels, regardless of the source's interpretation metadata. + scene.render.pixel_aspect_x = scene.render.pixel_aspect_y = 1.0 + scene.render.film_transparent = True + scene.render.motion_blur_shutter = max(0.0, min(2.0, DATA["shutter"])) + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1.0 + scene["effectcraft_unit_scale"] = DATA["unitScale"] + scene["effectcraft_scale_is_arbitrary"] = True + scene["effectcraft_shutter_phase_degrees"] = DATA["shutterPhase"] + + collection = bpy.data.collections.new("EffectCraft Tracking") + scene.collection.children.link(collection) + camera_data = bpy.data.cameras.new("3D Tracker Camera") + camera = bpy.data.objects.new("3D Tracker Camera", camera_data) + collection.objects.link(camera) + scene.camera = camera + camera.rotation_mode = 'QUATERNION' + camera_data.type = 'PERSP' + camera_data.sensor_fit = 'HORIZONTAL' + camera_data.sensor_width = DATA["sensorWidth"] + camera_data.sensor_height = (DATA["sensorWidth"] * DATA["resolution"][1] + / DATA["resolution"][0]) + camera_data.show_background_images = True + camera_data.show_limits = True + extent = max([DATA["groundSize"], 1.0] + + [math.dist(p["location"], f["location"]) + for p in DATA["points"][::max(1, len(DATA["points"]) // 100)] + for f in DATA["frames"][::max(1, len(DATA["frames"]) // 10)]]) + camera_data.clip_start = max(0.00001, min(0.1, extent / 10000.0)) + camera_data.clip_end = max(1000.0, extent * 10.0) + + previous = None + for sample in DATA["frames"]: + frame = sample["frame"] + camera.location = sample["location"] + # Exported right/up/back vectors are COLUMNS of Blender's local → world basis. + quaternion = Matrix(sample["rotation"]).transposed().to_quaternion() + if previous is not None and previous.dot(quaternion) < 0: + quaternion.negate() + camera.rotation_quaternion = quaternion + previous = quaternion.copy() + camera.keyframe_insert(data_path="location", frame=frame) + camera.keyframe_insert(data_path="rotation_quaternion", frame=frame) + camera_data.lens = sample["lens"] + camera_data.keyframe_insert(data_path="lens", frame=frame) + camera_data.dof.use_dof = sample["dof"] + camera_data.dof.focus_distance = sample["focus"] + camera_data.dof.aperture_fstop = sample["fstop"] + camera_data.dof.aperture_blades = sample["blades"] + camera_data.dof.aperture_rotation = (sample["irisRotation"] + math.pi) % math.tau - math.pi + camera_data.dof.aperture_ratio = sample["irisRatio"] + for prop in ("use_dof", "focus_distance", "aperture_fstop", "aperture_blades", + "aperture_rotation", "aperture_ratio"): + camera_data.keyframe_insert(data_path="dof." + prop, frame=frame) + + path = _media_path() + if path is not None: + try: + image = bpy.data.images.load(path, check_existing=False) + image.source = DATA["media"]["kind"] + background = camera_data.background_images.new() + background.source = 'IMAGE' + background.image = image + background.alpha = 1.0 + background.display_depth = 'BACK' + background.frame_method = 'STRETCH' + user = background.image_user + user.use_auto_refresh = True + user.use_cyclic = False + user.frame_start = DATA["startFrame"] + user.frame_duration = max(1, min(1048574, image.frame_duration)) + if image.source == 'MOVIE': + for sample in DATA["frames"]: + # Blender uses 1-based image frames; sourceFrame is 0-based. + user.frame_offset = sample["sourceFrame"] - (sample["frame"] - user.frame_start) + camera_data.keyframe_insert( + data_path="background_images[0].image_user.frame_offset", + frame=sample["frame"]) + except (RuntimeError, ValueError, OSError) as error: + DATA["warnings"].append("Blender could not load the background video: " + str(error)) + else: + DATA["warnings"].append("Video not found: " + DATA["media"]["path"] + + ". Place it beside this script or relink the camera background.") + + if DATA["points"]: + mesh = bpy.data.meshes.new("Solved Track Points") + mesh.from_pydata([p["location"] for p in DATA["points"]], [], []) + mesh.update() + cloud = bpy.data.objects.new("Solved Track Points", mesh) + collection.objects.link(cloud) + cloud.show_in_front = True + cloud.hide_render = True + for name, kind, key in (("track_id", 'INT', "id"), ("reprojection_error", 'FLOAT', "error"), + ("first_frame", 'INT', "first"), ("last_frame", 'INT', "last")): + attr = mesh.attributes.new(name=name, type=kind, domain='POINT') + for dest, point in zip(attr.data, DATA["points"]): + # Track IDs are u32 in EffectCraft; keep the full IDs in the JSON text too. + dest.value = point[key] if kind == 'FLOAT' else min(2147483647, point[key]) + + if DATA["ground"]: + size = DATA["groundSize"] + mesh = bpy.data.meshes.new("Solved Ground Plane") + mesh.from_pydata([(-size, -size, 0), (size, -size, 0), + (size, size, 0), (-size, size, 0)], [], [(0, 1, 2, 3)]) + mesh.update() + ground = bpy.data.objects.new("Solved Ground Plane", mesh) + collection.objects.link(ground) + ground.display_type = 'WIRE' + ground.hide_render = True + for marker in DATA["markers"]: + scene.timeline_markers.new(marker["name"], frame=marker["frame"]) + + _interpolation(camera) + _interpolation(camera_data) + metadata = bpy.data.texts.new("EffectCraft Tracking Metadata.json") + metadata.write(json.dumps(DATA, ensure_ascii=False, indent=2)) + scene["effectcraft_metadata_text"] = metadata.name + camera_data["effectcraft_average_error_pixels"] = DATA["solve"]["averageError"] + camera_data["effectcraft_solve_method"] = DATA["solve"]["methodUsed"] + camera_data["effectcraft_focus_source"] = "See warnings in " + metadata.name + distortion = DATA["solve"].get("distortion") + if distortion: + for key in ("k1", "k2", "radius"): + camera_data["effectcraft_distortion_" + key] = distortion[key] + if bpy.context.window is not None: + bpy.context.window.scene = scene + for area in bpy.context.window.screen.areas: + if area.type == 'VIEW_3D': + area.spaces.active.region_3d.view_perspective = 'CAMERA' + scene.frame_set(scene.frame_start) + for warning in DATA["warnings"]: + print("EffectCraft: " + warning) + print("EffectCraft: imported", len(DATA["frames"]), "baked camera frames into", scene.name) + return scene + + +if __name__ == "__main__": + EFFECTCRAFT_SCENE = import_scene() diff --git a/crates/engine/src/cmd/camera_cmds.rs b/crates/engine/src/cmd/camera_cmds.rs index 68cd14fb9..e717d51cc 100644 --- a/crates/engine/src/cmd/camera_cmds.rs +++ b/crates/engine/src/cmd/camera_cmds.rs @@ -42,11 +42,25 @@ pub fn specs() -> Vec { create_from_solve ), cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))), + cmd!( + "camera.exportBlender", + "Export Scene to Blender...", + [], + None, + "{path: .py, comp?, layer?, effect?, sensorWidth?: mm (default 36), unitScale?: Blender units per comp pixel (default 0.01), startFrame?: positive integer (default 1), includePoints?: true}", + has_comp, + export_blender + ), cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground), cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points), ] } +fn export_blender(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = ct_p(s, p, "camera.exportBlender")?; + super::blender::export(s, p, cid, lid, uid) +} + fn with_kind(p: &Value, kind: &str) -> Value { let mut q = p.clone(); if !q.is_object() { diff --git a/crates/engine/src/cmd/comp.rs b/crates/engine/src/cmd/comp.rs index 6648a7426..8bc0931f8 100644 --- a/crates/engine/src/cmd/comp.rs +++ b/crates/engine/src/cmd/comp.rs @@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, dx, dy); } diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 478dc8259..83c49bad2 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & let (old_w, old_h) = (c.width, c.height); c.width = w; c.height = h; - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, -x, -y); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30c027deb..dd1685722 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { let present: Vec = comp.layers.iter().map(|l| l.id).collect(); let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); - for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) { l.parent = l.parent.and_then(link); l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); } @@ -685,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result { } } if let Some(comp) = proj.comp_mut(cid) { - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.label = lab; } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index beb161a53..e1f58a1cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -746,7 +785,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 59a824208..c5018ae7c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; @@ -594,6 +601,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -687,9 +819,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -749,6 +895,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -1090,7 +1248,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs index 1bd0d2236..2ec9f4be2 100644 --- a/crates/engine/src/cmd/footage_panel.rs +++ b/crates/engine/src/cmd/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { @@ -207,10 +213,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 26c4b4c8c..eb9641547 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ), diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index a6f49abf8..870d22f5f 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -526,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.parent = parent; if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { f.apply(l); diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 7679d34fa..366c1eeb1 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; let target = v.unwrap_or(!first); - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { set_switch_value(l, &name, target); last = target; } @@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, alone &= on == (ids.contains(&l.id) == active); } let others = value.unwrap_or(alone == active); - for l in comp.layers.iter_mut().filter(|l| applies(l)) { + for l in comp.layers.iter_mut_where(|l| applies(l)) { let v = if ids.contains(&l.id) { active } else { others }; set_switch_value(l, name, v); } @@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); s.edit("Layer Comment", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.comment = text.clone(); } Ok(()) @@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { } s.edit("Blending Mode", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.blend_mode = match mode { Some(m) => m, None if step > 0 => l.blend_mode.next(), diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 78f6cdc3c..0427dcf0d 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); let comp_dur = comp.duration; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; @@ -161,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { for i in 1..n { if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); - comp.layers[i - 1..run_end].rotate_left(1); + comp.layers.rotate_range_left(i - 1..run_end, 1); } } } else { for i in (0..n.saturating_sub(1)).rev() { if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); - comp.layers[run_start..=i + 1].rotate_right(1); + comp.layers.rotate_range_right(run_start..=i + 1, 1); } } } diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index ec8a1dfc3..e0c4efc72 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result { q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))), }; s.edit("Layer Quality", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.quality = q; } Ok(()) @@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result { q => q, }; s.edit("Layer Sampling", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.sampling = q; } Ok(()) @@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))), }; s.edit("Frame Blending", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.frame_blend = m; } Ok(()) @@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { s.edit("Hide Other Video", None, |proj, _| { // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's // eye cell shows): the selection stays shown. - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) { l.switches.video = false; } Ok(()) @@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; // Track mattes stay hidden (their video switch is off by design). let mattes: Vec = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect(); - for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) { l.switches.video = true; } Ok(()) @@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "enabled").unwrap_or(true); let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| { let mut n = 0; - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.props.walk_mut(&mut |pr| { if let Some(e) = &mut pr.expr { e.enabled = on; @@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { let r = s.edit("Lock Markers", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked)); - for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.markers_locked = target; } Ok(target) @@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { fn delete_all_markers(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Delete All Markers", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) { l.markers.clear(); } Ok(()) diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 368e280c6..4ecf725ec 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let old = l.stretch; if reverse { // Keep the in/out points; play the visible span backwards. @@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut on = false; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) { let has = l.props.get("timeRemap").is_some(); let target = want.unwrap_or(!has); on |= target; diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index 28b7274ef..2cc4d34e6 100644 --- a/crates/engine/src/cmd/model3d.rs +++ b/crates/engine/src/cmd/model3d.rs @@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); } + if r == Renderer::Classic3D { + let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment }); + if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) { + s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer"); + } + } Ok(json!({"renderer": renderer_id(r)})) } @@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str { /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed. -fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { +pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let switched = c.renderer != Renderer::Advanced3D; c.renderer = Renderer::Advanced3D; @@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { +pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); - s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}")); } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 7803e8fac..8364f67ec 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -5,6 +5,7 @@ use effectcraft_keyframe::{ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid}; +use effectcraft_render::EvalCtx; use serde_json::{Value, json}; use super::layer_menu::target_masks; @@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result { /// The parametric shape paths that Convert To Bezier Path replaces with an editable path. const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"]; -/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the -/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted -/// path is the shape that was on screen rather than an approximation of it; the defaults match -/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness). -fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option { - let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64()); - let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2()); +/// The path a parametric shape group draws, in the group's own space. This is the geometry the +/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the +/// converted path is the shape that was on screen rather than an approximation of it; the defaults +/// match that code for a property a group doesn't carry (a polygon has no inner radius or +/// roundness). +fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option { + let f = |id: &str, d: f64| ctx.f(layer, g, id, d); + let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d); let path = match g.match_id.as_str() { - "rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), + "rect" => { + let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2])); + if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)], + ) + } else { + effectcraft_path::rect(size, pos, f("roundness", 0.0)) + } + } "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), "star" => effectcraft_path::polystar(effectcraft_path::Polystar { - star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + star: ctx.e(layer, g, "type") == 0, points: f("points", 5.0), pos: v2("position", [0.0; 2]), rotation: f("rotation", 0.0), @@ -160,28 +173,38 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { } let t = s.time(); let n = shapes.len(); + // Read the geometry before editing, with the same expression-aware context the renderer + // uses: an expression-driven corner radius, size or position must convert to what is on + // screen, not to the property's underlying value. + let prepared = { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = EvalCtx { expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) }; + let mut prepared = Vec::new(); + for (lid, uid) in &shapes { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue }; + let Some(path) = parametric_path(&ctx, l, old) else { continue }; + // Path Direction is the shape's own, not a default: reversing is a separate command. + prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled)); + } + prepared + }; let made = s.edit("Convert To Bezier Path", None, |proj, st| { let mut next = proj.next_id; let mut made = Vec::new(); - for (lid, uid) in &shapes { - let l = layer_mut(proj, cid, *lid)?; - let lt = l.layer_time(t); + for (lid, uid, path, direction, enabled) in prepared { + let l = layer_mut(proj, cid, lid)?; let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?; - let Some(old) = contents.find_group(*uid) else { continue }; - let Some(path) = parametric_path(old, lt) else { continue }; - // Path Direction is the shape's own, not a default: reversing is a separate command. - let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum()); - let enabled = old.enabled; let mut g = build::shape_path(&mut Ids(&mut next), path); if let Some(pr) = g.prop_mut("direction") { pr.value = KV::Enum(direction); } g.enabled = enabled; let new_uid = g.uid; - let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; - let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue }; + let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; + let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue }; parent.children[at] = Node::Group(g); - made.push((*lid, new_uid)); + made.push((lid, new_uid)); } proj.next_id = next; // The new paths are what is selected, so the vertices can be edited straight away. @@ -360,7 +383,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", - "wiggle", "merge", "front", "bevel", "side", "back", + "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid). diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ab2f0a1e2..663429fb2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +fn set_muted(s: &mut Session, p: &Value) -> Result { + let muted = b_p(p, "muted").unwrap_or(true); + let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" }; + edit_keys(s, label, None, move |keys, i, _| { + keys[i].muted = muted; + false + }) +} + /// A number or a per-dimension array of numbers. fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { match p.get(k)? { @@ -1381,6 +1390,7 @@ pub fn specs() -> Vec { &json!({"which": "out"}) )), cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold), + cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted), cmd!( "keys.interpolation", "Keyframe Interpolation...", diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index d15f93730..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index 5b17fcffe..dbcaa3c3c 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -277,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { set_expr(&mut cam, "cameraOptions/zoom", ze); cam.auto_orient = AutoOrient::TowardsPointOfInterest; let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?; - comp.layers = vec![cam, nest]; + comp.layers = vec![cam, nest].into(); eye_comps.push(eid); } // The output comp: both eyes through 3D Glasses. @@ -300,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { fxg.children.push(g.into()); } let (lid, rid) = (left.id, right.id); - proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right]; + proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into(); st.project_selection = vec![oid]; Ok(json!({ "controls": ctl_id.0, diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index f4162043d..8c14e03ec 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -7,6 +7,9 @@ //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of //! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; /// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. const PANELS_FOLDER: &str = "ScriptUI Panels"; /// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in @@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let hosts_before = s.script_ui.task_hosts.clone(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + if !quitting { + // The Startup script's context: the one that scheduled tasks or holds the windows it + // opened (a task scheduled after its dialog closes runs there too). + let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied(); + let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host); + let hosts: Vec = tasks.chain(windows).collect(); + for h in hosts { + if !s.script_ui.startup_hosts.contains(&h) { + s.script_ui.startup_hosts.push(h); + } + } + } + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -414,6 +510,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", @@ -463,10 +577,19 @@ pub fn specs() -> Vec { "Mouse Event on Script Window Control", [], None, - "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", + "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", always, scriptui::mouse ), + cmd!( + "scriptui.resize", + "Resize Script Window", + [], + None, + "{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)", + always, + scriptui::resize + ), cmd!( "scriptui.close", "Close Script Window", @@ -948,4 +1071,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index 1cf470ebe..f7b1c3e08 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -12,6 +12,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, insert_layer}; +use super::model3d::{report_switch, use_advanced_3d}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; @@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); - let id = s.edit("New Light", None, |proj, st| { + // Only the Advanced 3D renderer lights with Environment lights, so adding one switches a + // Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). + let env = kind == LightKind::Environment; + let (id, switched) = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; - insert_layer(proj, st, cid, l) + let id = insert_layer(proj, st, cid, l)?; + Ok((id, if env { use_advanced_3d(proj, cid)? } else { false })) })?; - Ok(json!({"layer": id.0})) + report_switch(s, cid, switched, "which Environment lights need to light the scene"); + Ok(json!({"layer": id.0, "advanced3d": env})) } fn light_settings(s: &mut Session, p: &Value) -> Result { @@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { ), None => None, }; - s.edit("Light Settings", None, |proj, _| { + let switched = s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); if let Some(k) = kind @@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; + // A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D, + // like adding one does; editing one that already is leaves the renderer to the user. + let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment }); + let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - Ok(()) + Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false }) })?; + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) } @@ -533,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { }; s.edit("Auto-Orient", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let rig = l.is_camera() || l.is_light(); let ok = match mode { AutoOrient::Off | AutoOrient::AlongPath => true, @@ -683,7 +694,7 @@ pub fn specs() -> Vec { "Light...", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), - "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", + "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_light ), @@ -710,7 +721,7 @@ pub fn specs() -> Vec { "Light Settings...", [], None, - "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", + "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", has_comp, light_settings ), diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 267985e37..16d7c1d5d 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -9,31 +9,35 @@ //! render reads the new pixels. //! //! The automatic part compares each footage file's size and modification time with what they -//! were when the file was last seen. The desktop app takes those stamps every two seconds (and -//! when its window comes back to the front) on a worker thread, so a file on a slow network share -//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them -//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload -//! records the state of the disk: it is not an undo step and doesn't modify the project. +//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that +//! stamps the files every two seconds while the window is in front (every ten seconds while it +//! is not, and at once when it comes back to the front) and wakes the interface only when a stamp +//! changed, so a file on a slow network share never holds up the interface and an idle app never +//! redraws for footage that didn't change; `footage.reloadChanged` takes the stamps on the spot +//! (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload records +//! the state of the disk: it is not an undo step and doesn't modify the project. use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer}; use serde_json::{Value, json}; -use crate::Session; +use crate::{Services, Session}; /// A file's size and modification time (see [`crate::Services::stamp`]). pub type Stamp = (u64, u128); -/// Stamps taken on a worker thread, waiting for [`poll_scan`]. -pub type Scan = Arc)>>>>; +/// The stamps of a set of files, in path order. +pub type Stamps = Vec<(String, Option)>; /// The files footage items read: each item's file and every frame of an image sequence. Image /// sequences count only with Automatically Reload Footage set to All Footage (After Effects' /// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up). /// Items with a stored path that is not followed ([`crate::doc_path`]) are not watched. -fn watched(s: &Session) -> BTreeSet { +pub fn watched(s: &Session) -> BTreeSet { let sequences = s.prefs.import.auto_reload_footage == "all"; let mut paths = BTreeSet::new(); for it in s.project.items.values() { @@ -267,31 +271,253 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result { apply_stamps(s, now) } -/// Start stamping the watched files on a worker thread, unless a scan is still running (or the -/// build has no threads: the web app, whose footage lives in browser storage). -pub fn start_scan(s: &mut Session) { - if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") { - return; +/// How often the watched files are stamped while the window is in front. +pub const FOCUSED_INTERVAL: Duration = Duration::from_secs(2); +/// How often they are stamped while another app has the front: a file saved there is noticed +/// as soon as the window comes back, so this only covers a second screen. +pub const UNFOCUSED_INTERVAL: Duration = Duration::from_secs(10); + +enum Watch { + Paths(BTreeSet), + Focused(bool), +} + +/// The desktop app's footage watcher: a worker thread that stamps the watched files and hands +/// the stamps over ([`Watcher::take`]) only when one of them changed since it last looked, +/// waking the interface through the callback given to [`Watcher::start`]. The interface tells it +/// what to watch and whether the window is in front; nothing watched means the thread sleeps. +/// Dropping the watcher ends the thread. +pub struct Watcher { + tx: Sender, + result: Arc>>, + paths: BTreeSet, + focused: Option, +} + +impl Watcher { + /// Start the worker, stamping through `services`; `wake` runs on the worker whenever new + /// stamps are ready to [`Watcher::take`]. `None` where there are no threads (the web app, + /// whose footage lives in browser storage) or one couldn't be started. + pub fn start(services: Arc, wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_with(services, wake, FOCUSED_INTERVAL, UNFOCUSED_INTERVAL) } - let paths = watched(s); - if paths.is_empty() { - return; + + /// [`Watcher::start`] with its own stamping intervals (tests). + pub fn start_with(services: Arc, wake: impl Fn() + Send + Sync + 'static, focused: Duration, unfocused: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::channel(); + let result: Arc>> = Arc::default(); + let out = Arc::clone(&result); + std::thread::Builder::new().name("footage-watch".into()).spawn(move || watch(&rx, &*services, &out, &wake, focused, unfocused)).ok()?; + Some(Watcher { tx, result, paths: BTreeSet::new(), focused: None }) } - let services = s.services.clone(); - let slot: Scan = Arc::default(); - let out = slot.clone(); - let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || { - let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect(); - *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps); - }); - if spawned.is_ok() { - s.footage_scan = Some(slot); + + /// The files to watch ([`watched`]); a changed set is stamped at once. + pub fn set_watched(&mut self, paths: BTreeSet) { + if paths != self.paths { + self.paths = paths.clone(); + let _ = self.tx.send(Watch::Paths(paths)); + } + } + + /// Whether the window is in front; coming back to the front stamps the files at once. + pub fn set_focused(&mut self, focused: bool) { + if self.focused != Some(focused) { + self.focused = Some(focused); + let _ = self.tx.send(Watch::Focused(focused)); + } + } + + /// Stamps the worker found changed (or the first ones for a new set of files), once; for + /// [`apply_stamps`]. `None` while nothing changed. + pub fn take(&self) -> Option { + self.result.lock().unwrap_or_else(PoisonError::into_inner).take() } } -/// Apply a finished scan (`None` while there is none, or it is still running). -pub fn poll_scan(s: &mut Session) -> Option> { - let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; - s.footage_scan = None; - Some(apply_stamps(s, stamps)) +/// The watcher's worker: wait for a message or the interval, stamp, report only changes. +fn watch( + rx: &Receiver, + services: &dyn Services, + out: &Mutex>, + wake: &(dyn Fn() + Send + Sync), + focused_every: Duration, + unfocused_every: Duration, +) { + let mut paths = BTreeSet::new(); + let mut focused = true; + let mut last: Option = None; + loop { + let msg = if paths.is_empty() { + rx.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + rx.recv_timeout(if focused { focused_every } else { unfocused_every }) + }; + match msg { + Ok(Watch::Paths(p)) => { + paths = p; + // A new set is reported whole, so the session forgets files no longer watched. + last = None; + } + Ok(Watch::Focused(f)) => { + let regained = f && !focused; + focused = f; + if !regained { + continue; + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + if paths.is_empty() { + continue; + } + let stamps: Stamps = paths.iter().map(|p| (p.clone(), services.stamp(p))).collect(); + if last.as_ref() != Some(&stamps) { + last = Some(stamps.clone()); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + wake(); + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod watcher_tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + /// Files whose stamps the test sets; counts how often they were stamped. + #[derive(Default)] + struct Disk { + stamps: Mutex>, + looks: AtomicUsize, + } + impl Disk { + fn set(&self, path: &str, stamp: Stamp) { + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), stamp); + } + } + impl Services for Disk { + fn read_file(&self, path: &str) -> std::io::Result> { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other("read-only")) + } + fn stamp(&self, path: &str) -> Option { + self.looks.fetch_add(1, Ordering::SeqCst); + self.stamps.lock().unwrap_or_else(PoisonError::into_inner).get(path).copied() + } + } + + const TICK: Duration = Duration::from_millis(20); + const LONG: Duration = Duration::from_secs(60); + + fn wait_for(w: &Watcher, wakes: &AtomicUsize, n: usize) -> Stamps { + let t0 = std::time::Instant::now(); + loop { + if wakes.load(Ordering::SeqCst) >= n + && let Some(s) = w.take() + { + return s; + } + assert!(t0.elapsed() < Duration::from_secs(10), "no report {n} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + /// Wait (at most 10 s) until `ready`: a slow machine's worker gets there late, never early. + fn wait_until(what: &str, mut ready: impl FnMut() -> bool) { + let t0 = std::time::Instant::now(); + while !ready() { + assert!(t0.elapsed() < Duration::from_secs(10), "{what} within 10 s"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn start(disk: &Arc, focused: Duration, unfocused: Duration) -> Option<(Watcher, Arc)> { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let w = Watcher::start_with( + Arc::clone(disk) as Arc, + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + focused, + unfocused, + )?; + Some((w, wakes)) + } + + /// The interface is woken for the first stamps of a set and for every change, and for + /// nothing else: an unchanged file polled many times is no wakeup at all. + #[test] + fn the_watcher_wakes_the_interface_only_when_a_stamp_changes() { + let disk = Arc::new(Disk::default()); + disk.set("/a.psd", (10, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(true); + w.set_watched(["/a.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/a.psd".to_string(), Some((10, 1)))]); + let looked = disk.looks.load(Ordering::SeqCst); + wait_until("it keeps stamping while the window is in front", || disk.looks.load(Ordering::SeqCst) > looked + 3); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "an unchanged file is no wakeup"); + assert!(w.take().is_none()); + disk.set("/a.psd", (12, 2)); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/a.psd".to_string(), Some((12, 2)))]); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + } + + /// Behind another app the files are stamped only at the slow interval; the window coming + /// back to the front stamps them at once. + #[test] + fn behind_another_app_the_watcher_rests_until_the_window_is_back() { + let disk = Arc::new(Disk::default()); + disk.set("/b.psd", (1, 1)); + let (mut w, wakes) = start(&disk, TICK, LONG).unwrap(); + w.set_focused(false); + w.set_watched(["/b.psd".to_string()].into()); + wait_for(&w, &wakes, 1); + let looked = disk.looks.load(Ordering::SeqCst); + disk.set("/b.psd", (2, 2)); + std::thread::sleep(TICK * 15); + assert_eq!(disk.looks.load(Ordering::SeqCst), looked, "no stamping at the front interval while behind"); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + w.set_focused(true); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/b.psd".to_string(), Some((2, 2)))]); + } + + /// Nothing to watch: the worker sleeps, stamping nothing; a new set is stamped at once and + /// reported whole, and dropping the watcher ends the worker. + #[test] + fn nothing_watched_is_nothing_stamped_and_a_new_set_is_reported_whole() { + let disk = Arc::new(Disk::default()); + disk.set("/c.psd", (1, 1)); + disk.set("/d.psd", (2, 2)); + let (mut w, wakes) = start(&disk, TICK, TICK).unwrap(); + w.set_focused(true); + std::thread::sleep(TICK * 10); + assert_eq!(disk.looks.load(Ordering::SeqCst), 0); + w.set_watched(["/c.psd".to_string(), "/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 1), vec![("/c.psd".to_string(), Some((1, 1))), ("/d.psd".to_string(), Some((2, 2)))]); + w.set_watched(["/d.psd".to_string()].into()); + assert_eq!(wait_for(&w, &wakes, 2), vec![("/d.psd".to_string(), Some((2, 2)))], "the set is reported whole, so the session forgets /c.psd"); + w.set_watched(BTreeSet::new()); + // The worker may already be past its wait with the old set: that one round of stamps + // (one file) can still come, and nothing after it. + let looked = disk.looks.load(Ordering::SeqCst); + std::thread::sleep(TICK * 10); + assert!(disk.looks.load(Ordering::SeqCst) <= looked + 1, "nothing watched, nothing stamped"); + let result = Arc::downgrade(&w.result); + drop(w); + let t0 = std::time::Instant::now(); + while result.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still running"); + std::thread::sleep(Duration::from_millis(5)); + } + } } diff --git a/crates/engine/src/footage/media_browser.rs b/crates/engine/src/footage/media_browser.rs index c0e20be71..2c40fcf20 100644 --- a/crates/engine/src/footage/media_browser.rs +++ b/crates/engine/src/footage/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -431,6 +431,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index 656bd7708..7503178fa 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp { [440.0, 914.0], ); anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))])); - c.layers = vec![title, bar]; + c.layers = vec![title, bar].into(); c } @@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { ); anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))])); - c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor]; + c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into(); c } @@ -463,7 +463,7 @@ pub fn demo_project() -> Project { lower.out_point = t(9.5); // Stack: top first. - comp.layers = vec![lower, title, sub, line, burst, rings, null, bg]; + comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into(); for l in &mut comp.layers { if l.name == "Controller" { l.switches.video = false; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index db9e02cda..a7484a3f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -241,8 +241,6 @@ pub struct Session { /// Each footage file's size and modification time when last seen (Settings ▸ Import ▸ /// Reload Footage Changed on Disk, [`footage_reload`]). pub footage_stamps: std::collections::HashMap, - /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). - pub footage_scan: Option, /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one @@ -351,7 +349,6 @@ impl Default for Session { stored_paths: Default::default(), footage_stamps: Default::default(), disk_salt: None, - footage_scan: None, script_calls: ScriptCalls::default(), } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9296e1322..a7418ae58 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1036,6 +1036,8 @@ Animation --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold + Mute Keyframes | keys.setMuted + Unmute Keyframes | keys.setMuted {"muted":false} Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index a83f494d2..d8f8cf7b5 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -686,7 +686,7 @@ impl Session { continue; } if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { + for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { l.source = LayerSource::Footage { item: fid }; } } diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d218a7bb..2c211e77a 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -402,12 +402,16 @@ pub struct ScriptUi { #[serde(skip)] pub tasks: Option, /// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating - /// tasks of scripts without an open window stop then. + /// tasks of scripts without an open window stop then, except those of Startup scripts. #[serde(skip)] pub project_epoch: u64, /// The script contexts of this session with scheduled tasks (kept by the scripting engine). #[serde(skip)] pub task_hosts: Vec, + /// The script contexts of Startup scripts: their tasks go on when the project is closed or + /// replaced, until EffectCraft quits. + #[serde(skip)] + pub startup_hosts: Vec, } impl ScriptUi { @@ -755,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { "screenX": num("screenX", -FAR, FAR, 0.0), "screenY": num("screenY", -FAR, FAR, 0.0), "button": num("button", 0.0, 2.0, 0.0).round(), + "buttons": num("buttons", 0.0, 7.0, 0.0).round(), "detail": num("detail", 0.0, 1000.0, clicks).round(), "altKey": flag("altKey"), "ctrlKey": flag("ctrlKey"), @@ -764,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value }) } +/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the +/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`. +pub(crate) fn resize(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.resize"; + let window = window_p(s, p, c)?; + let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?; + let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0)); + let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) }; + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) }) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 6f2d273ad..86127a510 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -96,7 +96,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/settings/prefs/choices.rs b/crates/engine/src/settings/prefs/choices.rs index 4145e0161..8935507a6 100644 --- a/crates/engine/src/settings/prefs/choices.rs +++ b/crates/engine/src/settings/prefs/choices.rs @@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Deutsch", "de"), ("Español", "es"), ("Français", "fr"), + ("Polski", "pl"), ("Português (Brasil)", "pt-br"), ("Русский", "ru"), ("Українська", "uk"), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index aa683e2b8..4cba41851 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -153,7 +153,8 @@ page!(Import { sequence_fps: f64 = 30.0, report_missing_frames: bool = true, /// Automatically Reload Footage whose file another app changed (size or modification time), - /// checked every two seconds and when the window comes back to the front: `off`, + /// checked every two seconds while the window is in front, every ten seconds while it is + /// not, and when it comes back to the front: `off`, /// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`. auto_reload_footage: String = "nonSequence".into(), /// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`. diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 609ad5987..4fe47e0ef 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -195,9 +195,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 2eb6fa680..1d60971ac 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -436,7 +458,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/engine/src/system/perf.rs b/crates/engine/src/system/perf.rs index ab1723d9f..929726c95 100644 --- a/crates/engine/src/system/perf.rs +++ b/crates/engine/src/system/perf.rs @@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { *n += 1; layers.push(l); } - comp.layers = layers; + comp.layers = layers.into(); }; let others = spec.comps.saturating_sub(1); let depth = spec.nest_depth.min(others); @@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { n += 1; layers.push(l); } - main.layers = layers; + main.layers = layers.into(); p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); // `Main` must be the first comp `replace_project` opens: give it the smallest id. let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25d05b903..26092967f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ mod anim_presets; mod anim_tools; +mod blender; mod camera_track; mod codec_options; mod color_view; @@ -41,6 +42,7 @@ pub(crate) mod roto; mod sequence; mod session; mod settings; +mod shared_layers; mod shortcut_cmds; pub(crate) mod stubs; mod styles; diff --git a/crates/engine/src/tests/blender.rs b/crates/engine/src/tests/blender.rs new file mode 100644 index 000000000..84e939055 --- /dev/null +++ b/crates/engine/src/tests/blender.rs @@ -0,0 +1,261 @@ +//! Synthetic camera interchange: projection, timing, DOF and hostile inputs. Optional public +//! Blender-API oracle: EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture. + +use effectcraft_effects::camera_tracker as ct; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, ItemKind, LayerId, LayerSource, build}; +use effectcraft_time::{FrameRate, Tick}; +use effectcraft_track::camtrack::{CameraSolve, Distortion, Ground, ShotType, SolveMethod, SolvedFrame, SolvedPoint}; +use serde_json::{Value, json}; + +use crate::Session; + +fn path(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("effectcraft-blender-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name).to_string_lossy().into_owned() +} + +fn fixture(ground: bool) -> (Session, LayerId, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Shot '\\ — câmera", "width": 640, "height": 360, "duration": 6})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + let lid = s + .edit("fixture", None, |p, _| { + p.comp_mut(cid).unwrap().frame_rate = rate; + let item = p.add_item( + "Plate", + Default::default(), + None, + ItemKind::Footage(Footage { + path: std::env::var("EFFECTCRAFT_BLENDER_PLATE").unwrap_or_else(|_| path("plate '\\ — vídeo.mp4")), + width: 640, + height: 360, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(6.0), + kind: FootageKind::Video, + has_video: true, + ..Default::default() + }), + ); + let comp = p.comp(cid).unwrap().clone(); + let mut layer = build::layer(p, &comp, "Tracked Video", LayerSource::Footage { item }, (640, 360), None); + layer.in_point = rate.tick_of(60); + layer.out_point = rate.tick_of(63); + layer.start_time = rate.tick_of(30); + layer.stretch = 200.0; + let id = layer.id; + p.comp_mut(cid).unwrap().layers.push(layer); + Ok(id) + }) + .unwrap(); + let uid = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0]})).unwrap()["effects"][0].as_u64().unwrap(); + let solve = CameraSolve { + version: 1, + size: [640.0, 360.0], + start: rate.tick_of(30).seconds() / 2.0, + frame_duration: rate.frame_duration().seconds() / 2.0, + shot: ShotType::VariableZoom, + method_used: SolveMethod::Typical, + average_error: 0.12, + frames: (0..3) + .map(|i| SolvedFrame { + rot: [0.02 * i as f64, -0.03 * i as f64, 0.01 * i as f64], + center: [0.1 * i as f64, -0.01 * i as f64, 0.02 * i as f64], + focal: 600.0 + i as f64 * 25.0, + solved: true, + }) + .collect(), + points: vec![ + SolvedPoint { id: 7, pos: [0.1, 0.2, 2.0], error: 0.13, first: 0, last: 2 }, + SolvedPoint { id: 9, pos: [-0.3, -0.1, 1.2], error: 0.11, first: 0, last: 1 }, + ], + ground: ground.then_some(Ground { origin: [0.1, 0.2, 2.0], normal: [0.0, -1.0, 0.0], x_axis: [1.0, 0.0, 0.0] }), + distortion: ground.then_some(Distortion { k1: 0.01, k2: -0.002, radius: 320.0_f64.hypot(180.0) }), + }; + // Directly install a deterministic solve; no analysis or decoder is needed by these tests. + s.execute("prop.set", json!({"layer": lid.0, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap(); + s.camera_pending.clear(); + s.execute("layer.select", json!({"layers": [lid.0]})).unwrap(); + (s, lid, uid) +} + +fn data(script: &str) -> Value { + let line = script.lines().find_map(|l| l.strip_prefix("DATA = json.loads(")).unwrap().strip_suffix(')').unwrap(); + let encoded: String = serde_json::from_str(line).unwrap(); + serde_json::from_str(&encoded).unwrap() +} + +#[test] +fn blender_export_bakes_projection_focus_and_retimed_background() { + let (mut s, lid, uid) = fixture(true); + let result = s.execute("camera.create", json!({"layer": lid.0, "effect": uid})).unwrap(); + let camera = result["camera"].as_u64().unwrap(); + s.execute("layer.cameraSettings", json!({"layer": camera, "focusDistance": 800.0, "dof": true, "fStop": 4.0, "lockToZoom": false})).unwrap(); + for (frame, value) in [(60, 800.0), (62, 1000.0)] { + let time = FrameRate::new(30000, 1001).tick_of(frame).seconds(); + s.execute("prop.addKey", json!({"layer": camera, "path": "cameraOptions/focusDistance", "time": time, "value": value})).unwrap(); + } + let camera_layer = s.active_comp().unwrap().layer(LayerId(camera)).unwrap(); + assert!(camera_layer.props.prop("cameraOptions/dof").unwrap().value.as_bool()); + let output = path("oracle-scene.py"); + let before = s.project.to_json(); + let r = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "effect": uid, "path": output, "startFrame": 101})).unwrap(); + assert_eq!(s.project.to_json(), before, "export must not edit the document"); + assert_eq!(r["frames"], 3); + assert_eq!(r["points"], 2); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert_eq!(d["resolution"], json!([640.0, 360.0])); + assert_eq!(d["fpsNumerator"], 30000); + assert_eq!(d["fpsDenominator"], 1001); + assert_eq!(d["frames"][0]["sourceFrame"], 15, "trim + 200% stretch, not scene frame 101"); + assert_eq!(d["frames"][2]["sourceFrame"], 16); + assert_eq!(d["frames"][0]["focus"], 8.0); + assert_eq!(d["frames"][2]["focus"], 10.0); + assert_eq!(d["frames"][0]["dof"], true); + assert!((d["frames"][0]["fstop"].as_f64().unwrap() - 4.0).abs() < 1e-6); + assert_eq!(d["frames"][0]["lens"], 33.75); + assert_eq!(d["frames"][2]["lens"], 36.5625); + assert_eq!(d["ground"], true); + assert_eq!(d["solve"]["distortion"]["k1"], 0.01); + assert_eq!(d["points"][0]["location"][2], 0.0, "ground point lies on Blender XY plane"); + + // Execute the actual exported artifact in a separately installed Blender, when requested. + if let Ok(blender) = std::env::var("EFFECTCRAFT_BLENDER") { + let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blender_scene_oracle.py"); + let output = std::process::Command::new(blender) + .args(["--background", "--factory-startup", "--python-exit-code", "1", "--python"]) + .arg(oracle) + .arg("--") + .arg(&output) + .output() + .unwrap(); + assert!(output.status.success(), "Blender oracle failed:\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("EFFECTCRAFT_BLENDER_ORACLE_OK")); + } +} + +#[test] +fn blender_export_bakes_reverse_time_remap_and_footage_loops() { + let (mut s, lid, _) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let rate = FrameRate::new(30000, 1001); + s.edit("retimed fixture", None, |p, _| { + let layer = p.comp(cid).unwrap().layer(lid).unwrap(); + let item = layer.source.item().unwrap(); + let keys = [(60, 8.0), (61, 6.0), (62, -1.0)] + .map(|(frame, time)| effectcraft_keyframe::Keyframe::new(layer.layer_time(rate.tick_of(frame)), KV::Scalar(time))); + let mut remap = build::Ids(&mut p.next_id).prop("timeRemap", "Time Remap", KV::Scalar(8.0)); + remap.keys = keys.to_vec(); + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.children.push(remap.into()); + if let ItemKind::Footage(f) = &mut p.item_mut(item).unwrap().kind { + f.loop_count = 2; + } + Ok(()) + }) + .unwrap(); + let output = path("remap.py"); + s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output})).unwrap(); + let d = data(&std::fs::read_to_string(output).unwrap()); + let indices: Vec = d["frames"].as_array().unwrap().iter().map(|f| f["sourceFrame"].as_i64().unwrap()).collect(); + assert_eq!(indices, vec![60, 0, 0], "reverse remap wraps the 6-second video and clamps negative source time, as the media pool does"); +} + +#[test] +fn blender_export_defaults_and_script_escaping() { + let (mut s, lid, _) = fixture(false); + let output = path("escaped.py"); + let r = s + .execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": output, "includePoints": false, "sensorWidth": 24.0, "unitScale": 0.1})) + .unwrap(); + assert_eq!(r["points"], 0); + let script = std::fs::read_to_string(&output).unwrap(); + let d = data(&script); + assert!(d["name"].as_str().unwrap().contains("'\\ — câmera")); + assert_eq!(d["frames"][0]["lens"], 22.5); + assert_eq!(d["frames"][0]["dof"], false); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("cannot be recovered"))); + match std::process::Command::new("python3").args(["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", &output]).status() { + Ok(status) => assert!(status.success(), "generated script must parse even with quoted/Unicode paths"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => eprintln!("Python AST oracle skipped: python3 is not installed"), + Err(e) => panic!("Python AST oracle failed: {e}"), + } +} + +#[test] +fn blender_export_rejects_invalid_arguments_and_write_failures() { + let (mut s, lid, _) = fixture(false); + for params in [ + json!({"sensorWidth": 0}), + json!({"sensorWidth": "wide"}), + json!({"unitScale": -1}), + json!({"startFrame": -1}), + json!({"startFrame": 1.5}), + json!({"startFrame": 1048574}), + json!({"includePoints": "yes"}), + json!({"bogus": true}), + json!({"path": path("bad.blend")}), + ] { + let mut p = json!({"layer": lid.0, "path": path("invalid.py")}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + assert!(s.execute_checked("camera.exportBlender", p.clone()).is_err(), "must reject {p}"); + } + let blocker = path("blocker"); + std::fs::write(&blocker, b"not a directory").unwrap(); + let p = format!("{blocker}/scene.py"); + assert!(s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": p})).unwrap_err().to_string().contains("cannot write")); +} + +#[test] +fn blender_export_rejects_malformed_solves_without_panicking() { + for variant in ["empty", "unsolved", "zero-focal", "bad-ground", "huge-resolution", "mismatched-range"] { + let (mut s, lid, uid) = fixture(false); + let cid = s.active_comp_id().unwrap(); + let mut solve = ct::solve(&crate::camera_track::static_params(s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap())) + .unwrap() + .as_ref() + .clone(); + match variant { + "empty" => solve.frames.clear(), + "unsolved" => solve.frames[0].solved = false, + "zero-focal" => solve.frames[0].focal = 0.0, + "bad-ground" => solve.ground = Some(Ground::default()), + "huge-resolution" => solve.size[0] = 1e10, + _ => { + solve.frames.pop(); + } + } + s.edit("corrupt solve", None, |p, _| { + p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.find_group_mut(uid).unwrap().prop_mut(ct::SOLVE).unwrap().value = KV::Str(solve.to_json()); + Ok(()) + }) + .unwrap(); + s.camera_pending.clear(); + let result = s.execute_checked("camera.exportBlender", json!({"layer": lid.0, "path": path("corrupt.py")})); + assert!(result.is_err(), "must reject {variant}"); + } +} + +#[test] +fn blender_export_respects_script_write_permission() { + let (mut s, lid, _) = fixture(false); + let output = path("script-permissions.py"); + let params = json!({"layer": lid.0, "path": output}); + for (from_script, allowed) in [(true, false), (true, true), (false, false), (false, true)] { + std::fs::write(&output, b"existing file").unwrap(); + s.prefs.scripting.allow_scripts_write_files = allowed; + let result = + if from_script { s.execute_for_script("camera.exportBlender", params.clone()) } else { s.execute_checked("camera.exportBlender", params.clone()) }; + assert!(!s.running_for_script(), "script origin must be restored after success or error"); + let contents = std::fs::read_to_string(&output).unwrap(); + if from_script && !allowed { + assert!(result.unwrap_err().to_string().contains("Allow Scripts to Write Files")); + assert_eq!(contents, "existing file", "blocked scripts must not replace an existing file"); + } else { + assert_eq!(result.unwrap()["frames"], 3); + assert!(contents.contains("def import_scene():")); + } + } +} diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs index 64baf14fe..4b3b0188e 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() { effectcraft_project::ItemKind::Comp(c) => c, _ => panic!("not a composition"), }); - let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap(); + let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap(); for n in &mut l.props.sub_mut("effects").unwrap().children { let Node::Group(g) = n else { continue }; if g.match_id == "ec.blur.gaussian" { diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index 93f81736f..cfbbb4966 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { assert!(s.state.selected_keys.is_empty()); } +#[test] +fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64(); + let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + select(&mut s); + let before = value_at(&s, 0.5); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + // The muted key no longer bends the 0 → 300 segment. + assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6); + assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6); + s.undo(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + assert!((value_at(&s, 0.5) - before).abs() < 1e-6); + // The same command unmutes. + select(&mut s); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + s.execute("keys.setMuted", json!({"muted": false})).unwrap(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); +} + +/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work. +#[test] +fn mute_and_unmute_keyframes_menu_rows_work_and_undo() { + fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) { + for n in nodes { + match n { + crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())), + crate::menus::MenuNode::Submenu { children, .. } => rows(children, out), + _ => {} + } + } + } + let mut menu = vec![]; + rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu); + assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}"); + + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted; + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + let run = |s: &mut Session, params: &Value| { + s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap(); + }; + run(&mut s, &menu[0].1); + assert!(muted(&s)); + s.undo(); + assert!(!muted(&s)); + s.redo(); + assert!(muted(&s)); + run(&mut s, &menu[1].1); + assert!(!muted(&s)); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/engine/src/tests/panels.rs b/crates/engine/src/tests/panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests/panels.rs +++ b/crates/engine/src/tests/panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 941a9a96b..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } @@ -64,7 +70,12 @@ impl FootageSource for Synthetic { for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -76,7 +87,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index d4a47c1cc..a29de9f1c 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { s.execute("file.import", params).unwrap(); let comp = s.active_comp().unwrap(); assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["a.png", "b.png", "Bg"]); - for l in &comp.layers[..2] { + for l in comp.layers.iter().take(2) { assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0]))); assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0)); } @@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() { assert_eq!(r["items"].as_array().map(Vec::len), Some(1)); } +/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what +/// the import made, first file on top, at once or when the background job lands; an import that +/// made nothing makes no comp. +#[test] +fn new_comp_from_footage_makes_a_comp_of_what_was_imported() { + use effectcraft_project::{Footage, FootageKind, ItemKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + if path.ends_with(".xyz") { + return Err("unsupported file".into()); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::>(); + // At once. + s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 1); + assert_eq!(layers(&s), ["a.png"]); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32)); + // In the background: the job's own items, whatever is selected or active meanwhile; it takes + // precedence over `addToComp`. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap(); + s.state.project_selection.clear(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(layers(&s), ["c.png", "d.png"]); + // Nothing imported: no comp, and the failure is reported. + s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert!(s.job_log.last().unwrap().message.contains("unsupported file")); + let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1)); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 03003a77b..f2fd5a3b7 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -1089,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Polish, likewise. + assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl")); + let mut pl = Session { config: Some(store.clone()), ..Default::default() }; + pl.load_settings(); + assert_eq!(pl.prefs.general.language, "pl"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl"); // Georgian too. assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); diff --git a/crates/engine/src/tests/shared_layers.rs b/crates/engine/src/tests/shared_layers.rs new file mode 100644 index 000000000..275fdd624 --- /dev/null +++ b/crates/engine/src/tests/shared_layers.rs @@ -0,0 +1,42 @@ +//! An edit copies only the layer it changes: the others stay shared with the undo state, so an +//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic). + +use std::sync::Arc; + +use serde_json::json; + +use crate::Session; + +#[test] +fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap(); + let ids: Vec = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect(); + let cid = s.active_comp_id().unwrap(); + let before = s.project.clone(); + s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap(); + let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap()); + assert_eq!(old.layers.len(), new.layers.len()); + let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap(); + for i in 0..new.layers.len() { + assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i)); + } + // Undo brings back the very same state, and the edited layer is the only one that differed. + s.execute("edit.undo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.project, &before)); +} + +#[test] +fn layers_save_and_load_as_a_plain_list() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap(); + for i in 0..3 { + s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let text = serde_json::to_value(&comp.layers).unwrap(); + assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before"); + let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap(); + assert_eq!(&back, &comp.layers); +} diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 9735f2c08..087a4ae1a 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } +/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so +/// the editable path is the shape that was on screen. +#[test] +fn convert_to_bezier_honours_per_corner_roundness() { + let mut s = comp(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + let per_corner = uid_of(&s, "perCorner"); + let top_left = uid_of(&s, "topLeftRoundness"); + let bottom_right = uid_of(&s, "bottomRightRoundness"); + for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] { + s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the converted path has the per-corner geometry"); +} + +/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven +/// Per-Corner checkbox and radius convert to the geometry on screen. +#[test] +fn convert_to_bezier_evaluates_corner_expressions() { + struct Corners; + impl effectcraft_render::ExprHost for Corners { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + prop: &effectcraft_project::Property, + value: &KV, + ) -> std::result::Result { + Ok(match prop.match_id.as_str() { + "perCorner" => KV::Bool(true), + "topLeftRoundness" => KV::Scalar(20.0), + _ => value.clone(), + }) + } + } + + let mut s = comp(); + s.expr = Some(Arc::new(Corners)); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] { + let u = uid_of(&s, id); + s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the expression-evaluated corner is converted"); +} + /// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a /// shared merge key; one undo must revert the whole drag, not the last frame. #[test] diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index 07a48af1c..748f8a32f 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -1,6 +1,6 @@ //! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. -use effectcraft_project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer}; use effectcraft_render::three_d::View3D; use serde_json::json; @@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are +/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches +/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does. +#[test] +fn environment_lights_switch_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() + }; + let mut s = session(); + let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + toasts(&mut s); + let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert_eq!(r["advanced3d"], true); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // As with Composition Settings: the text layer gets Geometry Options. + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some()); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D"))); + // A second Environment light leaves the comp (and the user) alone. + s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert!(toasts(&mut s).is_empty()); + // Undoing the creations takes the lights and the switch back together. + s.undo(); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(layer_count(&s), 1); + // Changing a light to Environment switches too, and undoes with the type. + let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Other light types never switch it. + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + // Switching back to Classic 3D with an Environment light in the comp explains itself. + let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap(); + toasts(&mut s); + s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); + assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained"); + // Editing that Environment light (no type change) leaves the renderer the user chose. + s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch"); + assert!(toasts(&mut s).is_empty(), "and doesn't toast either"); +} + +/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every +/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no +/// Falloff, which an environment light shining from every direction has no use for (#2436). +#[test] +fn environment_light_takes_shadow_options_but_no_falloff() { + let mut s = session(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"] + .as_u64() + .unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(), + "an environment light has no Falloff" + ); + // The shadow values carry over when the type changes, both ways. + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0); + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again"); + s.undo(); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(), + "back to Spot: it has a Cone Angle again" + ); +} + #[test] fn three_d_switch_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 9cfb94ecc..10c07c2ad 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -160,6 +160,37 @@ fn psd_imports_as_composition() { assert!(s.project.comp(cid).is_none()); } +/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that +/// composition; it isn't wrapped in another. +#[test] +fn psd_imported_as_composition_with_new_comp_is_the_new_comp() { + let path = tmp("newcomp.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count(); + assert_eq!(roots, 1, "no wrapper comp"); +} + +/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds +/// its composition once, not its folder too. +#[test] +fn psd_imported_as_composition_into_a_comp_adds_one_layer() { + let path = tmp("dropped.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let psd = r["comps"][0].as_u64().unwrap(); + let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers; + assert_eq!(layers.len(), 1, "{layers:?}"); + assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd)); +} + #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); diff --git a/crates/engine/tests/blender_scene_oracle.py b/crates/engine/tests/blender_scene_oracle.py new file mode 100644 index 000000000..3d1cbeda7 --- /dev/null +++ b/crates/engine/tests/blender_scene_oracle.py @@ -0,0 +1,61 @@ +"""Public-API oracle for scripts generated by src/tests/blender.rs; no Blender source dependency.""" +import json +import math +import runpy +import sys + +import bpy +from bpy_extras.object_utils import world_to_camera_view +from mathutils import Quaternion, Vector + +path = sys.argv[sys.argv.index("--") + 1] +before = set(bpy.data.scenes) +exported = runpy.run_path(path, run_name="__main__") +scene = exported["EFFECTCRAFT_SCENE"] +data = exported["DATA"] +assert before.issubset(set(bpy.data.scenes)), "existing scenes were removed" +assert scene not in before +assert scene.frame_start == 101 and scene.frame_end == 103 +assert abs(scene.render.fps / scene.render.fps_base - 30000 / 1001) < 1e-4 +assert (scene.render.resolution_x, scene.render.resolution_y) == (640, 360) +camera = scene.camera +assert camera.animation_data.action is not None +assert camera.data.animation_data.action is not None +assert camera.data.sensor_fit == 'HORIZONTAL' +metadata = json.loads(bpy.data.texts[scene["effectcraft_metadata_text"]].as_string()) +assert metadata["solve"]["averageError"] == 0.12 +cloud = scene.objects["Solved Track Points"] +assert len(cloud.data.vertices) == 2 +assert cloud.hide_render +assert cloud.data.attributes["track_id"].data[0].value == 7 +assert any(obj.name.startswith("Solved Ground Plane") for obj in scene.objects) + +for i, sample in enumerate(data["frames"]): + scene.frame_set(sample["frame"]) + bpy.context.view_layer.update() + assert abs(camera.data.lens - sample["lens"]) < 1e-5 + assert abs(camera.data.dof.focus_distance - sample["focus"]) < 1e-5 + assert abs(camera.data.dof.aperture_fstop - sample["fstop"]) < 1e-5 + assert camera.data.dof.use_dof + assert (camera.location - Vector(sample["location"])).length < 1e-5 + raw = data["solve"]["frames"][i] + rot = Vector(raw["rot"]) + rotation = Quaternion(rot.normalized(), rot.length).to_matrix() if rot.length else Quaternion().to_matrix() + for vertex, point in zip(cloud.data.vertices, data["solve"]["points"]): + relative = rotation @ (Vector(point["pos"]) - Vector(raw["center"])) + expected = (raw["focal"] * relative.x / relative.z + 320, + raw["focal"] * relative.y / relative.z + 180) + projected = world_to_camera_view(scene, camera, vertex.co) + actual = (projected.x * 640, (1.0 - projected.y) * 360) + assert math.dist(actual, expected) < 0.002, (sample["frame"], actual, expected) + + if "Video not found" not in " ".join(data["warnings"]): + background = camera.data.background_images[0] + assert background.image.source == 'MOVIE' + assert tuple(background.image.size) == (640, 360) + user = background.image_user + actual_source = sample["frame"] - user.frame_start + user.frame_offset + assert actual_source == sample["sourceFrame"], (actual_source, sample["sourceFrame"]) + assert user.use_auto_refresh + +print("EFFECTCRAFT_BLENDER_ORACLE_OK") diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c639ebad1..8e8c90561 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -431,12 +431,13 @@ impl<'a> Cx<'a> { fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); + // frame_size applies crop itself: use the original frame and the same scale as edges. + let (w, h) = om.frame_size(img.width, img.height, scale); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { return img; } - let (w, h) = om.frame_size(img.width, img.height, 1.0); if (w, h) == (img.width, img.height) { return img; } @@ -1062,6 +1063,91 @@ mod tests { } } + /// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440. + #[test] + fn crop_resize_applies_margins_once() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let output = OutputModule { + crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() }, + resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() }, + ..Default::default() + }; + assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440)); + let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0); + assert_eq!((img.width, img.height), (1280, 1440)); + assert_eq!(img.data.len(), 1280 * 1440); + } + + /// Apply output-module geometry to a synthetic frame without rendering or writing files. + fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { + use effectcraft_project::render_queue::RenderSettings; + let project = Project::default(); + let settings = RenderSettings { resolution: scale, ..Default::default() }; + let job = Job { + project: &project, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output, + path: "unused.png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + Cx::new(&job).crop_resize(img) + } + + /// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities. + #[test] + fn crop_resize_dimensions_match_the_output_module() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() }; + let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() }; + let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() }; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality }; + for (crop, resize, scale, expected) in [ + (Crop::default(), Resize::default(), 1.0, (8, 6)), + (edges, Resize::default(), 1.0, (4, 6)), + (Crop::default(), resize(4), 1.0, (4, 3)), + (edges, resize(8), 1.0, (8, 12)), + (padding, resize(5), 1.0, (5, 4)), + (roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)), + (roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)), + (edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)), + ] { + let output = OutputModule { crop, resize, ..Default::default() }; + assert_eq!(output.frame_size(8, 6, scale), expected); + let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale); + assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}"); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + } + } + } + + /// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes. + #[test] + fn crop_resize_zero_and_extreme_sizes_stay_bounded() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + for (size, crop, width, height, expected) in [ + ((8, 6), Crop::default(), 0, 0, (1, 1)), + ((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)), + ((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)), + ((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)), + ((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)), + ] { + let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() }; + let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0); + assert_eq!((img.width, img.height), expected); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + assert!(img.data.iter().all(|p| *p == [0.0; 4])); + } + } + } + #[test] fn crop_and_pad() { let mut img = Image::new(4, 3); diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 1f48efd1e..2171bfe57 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -61,7 +61,7 @@ fn project() -> (Project, ItemId) { } box_.blend_mode = BlendMode::Screen; - comp.layers = vec![box_, ring, solid]; + comp.layers = vec![box_, ring, solid].into(); let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -94,7 +94,9 @@ fn export_bytes(format: OutputFormat, name: &str) -> Vec { #[test] fn h264_movie_bytes_are_pinned() { let data = export_bytes(OutputFormat::H264, "/out/pinned.mp4"); - assert_eq!((data.len(), fnv1a(&data)), (104_180, 8_728_430_532_616_484_528), "H.264 MP4"); + // FilmCraft 09aaf9b's encoder lists its settings in the first frame's user-data SEI (211 + // bytes where 0.1.1's name took 50): 161 bytes more than the FilmCraft 2c5ba7a pin. + assert_eq!((data.len(), fnv1a(&data)), (104_341, 17_655_680_090_264_218_914), "H.264 MP4"); } #[test] diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 465d8e5fe..0fd6d471b 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,31 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel. +/// Compare against crop-only output for padding and scaled ROIs with both resize qualities. +#[test] +fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + for (crop, scale, expected) in [ + (Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)), + (Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)), + ] { + let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) }; + let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) }; + let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0); + assert_eq!(reference.dimensions(), expected); + assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content"); + for quality in [ResizeQuality::Low, ResizeQuality::High] { + output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality }; + let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0); + assert_eq!(actual.dimensions(), expected); + assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed"); + } + } +} + #[test] fn alpha_only_and_premultiplied() { let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { diff --git a/crates/export/tests/it/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs index e7c2b0346..1432e3892 100644 --- a/crates/export/tests/it/vp9_roundtrip.rs +++ b/crates/export/tests/it/vp9_roundtrip.rs @@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec, Vec, Vec) { } fn decode(frame: &[u8]) -> filmcraft_vp9::Picture { - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let mut pics = d.decode(frame, 0).expect("decode"); assert_eq!(pics.len(), 1); pics.remove(0) @@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { let (w, h) = (cfg.width as usize, cfg.height as usize); let mut e = Vp9Encoder::new(cfg); - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let (mut bytes, mut db) = (0usize, 0.0); for t in 0..n { let (y, u, v) = moving(w, h, t, kind); @@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() { } let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") }; // The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg. - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); for (t, (f, r)) in frames.iter().zip(&recon).enumerate() { let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}")); assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}"); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 8fe812590..3196ce5a3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { __seed = 0; __timeless = false; __projectInfo = null; + __layersTrim(); } function __finish(r) { @@ -44,7 +45,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (typeof r === 'function' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) { if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); - if (info === 'layer') return new Layer(c, l); + if (info === 'layer') return __layer(c, l); if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); return __mkGroup(c, l, info); } @@ -518,10 +519,10 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); - return new Layer(this.__c, id); + return __layer(this.__c, id); }, toString: function () { return '[object Comp]'; }, }; @@ -596,7 +597,55 @@ var thisProject = { // Layer ----------------------------------------------------------------------------------------- -function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } +// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's +// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English +// off). Callable like a group (`__mkGroup`), with the Layer methods. +function Layer(c, l) { + var f = function (k) { return f.__group(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Layer.prototype); + // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete + // a field or leave its own on it. Its info is private to `__info`, which reads it again in an + // evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so + // freezing the layer can't keep its info either. + var info = null, read = null; + Object.defineProperty(f, '__c', { value: c }); + Object.defineProperty(f, '__l', { value: l }); + Object.defineProperty(f, '__info', { + get: function () { + if (read !== __S) { info = __h(__LINFO, c, l); read = __S; } + return info; + }, + }); + Object.preventExtensions(f); + return f; +} + +// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain +// object, so it is kept, and its info (`__info`) is read again in each evaluation. +// The kept layers are out of an expression's reach (their maps have no prototype, so a change to +// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once +// `max` are kept, the layers after them aren't, and the next evaluation starts over +// (`__layersTrim`, so a layer stays the same object for a whole evaluation). +var __layer, __layersTrim; +(function () { + var layers = Object.create(null), kept = 0, max = 4096; + __layer = function (c, l) { + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = layers[c], f = m && m[l]; + if (f) return f; + f = new Layer(c, l); + if (kept < max) { + (m || (layers[c] = Object.create(null)))[l] = f; + kept++; + } + return f; + }; + __layersTrim = function () { + if (kept >= max) { layers = Object.create(null); kept = 0; } + }; +})(); function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -620,7 +669,6 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, @@ -632,7 +680,7 @@ Layer.prototype = { get parent() { var p = this.__info[7]; if (p === null) __err('Layer "' + this.name + '" has no parent'); - return new Layer(this.__c, p); + return __layer(this.__c, p); }, get hasVideo() { return this.__info[8]; }, get enabled() { return this.__info[11]; }, @@ -804,7 +852,7 @@ Prop.prototype = { propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; - if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'layer') return v === null ? null : __layer(this.__c, v); if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); return v; }, @@ -834,7 +882,7 @@ Prop.prototype = { for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); }, - __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + __seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; }, wiggle: function (freq, amp, octaves, ampMult, t) { if (octaves === undefined) octaves = 1; if (ampMult === undefined) ampMult = 0.5; @@ -903,8 +951,9 @@ var __propHandler = { var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; - // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). - if (x?.__isGroup) return x; + // Property groups and layers are callable: hand them over as they are, not bound like methods + // (#809). + if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; @@ -1082,18 +1131,18 @@ Markers.prototype = { // --------------------------------------------------------------------------------------------- // Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're // accessors on the global object; assigning one (`var width = 10`) shadows it for the current -// evaluation only. +// evaluation only. They can't be deleted or redefined, so an expression can't take one away from +// the evaluations after it. function __global(name, get) { Object.defineProperty(globalThis, name, { - configurable: true, get: function () { return name in __S.vars ? __S.vars[name] : get(); }, set: function (v) { __S.vars[name] = v; }, }); } __global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); -__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); }); __global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); ['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { @@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } delete globalThis.Proxy; delete globalThis.Reflect; })(); + +// --------------------------------------------------------------------------------------------- +// The runtime runs this file once and keeps it for the thread's later evaluations, so an +// expression can't change the object model or the internals: the prototypes are frozen, and the +// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is +// set again by `__begin` in each evaluation. + +[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) { + Object.freeze(f.prototype); + Object.freeze(f); +}); +[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze); +(function () { + var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true }; + var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true }; + Object.getOwnPropertyNames(globalThis).forEach(function (k) { + if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return; + if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); + }); +})(); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 55720a327..57d491de5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -77,7 +77,7 @@ fn fx() -> Fx { let ids = (a.id, b.id, title.id, shape.id); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![a, b, title, shape]; + c.layers = vec![a, b, title, shape].into(); c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); @@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); + assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] @@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() { } } +/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another +/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact +/// English off (they failed: "not a callable function"). It is still a layer. +#[test] +fn layers_are_callable_property_groups() { + let f = fx(); + assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); + // Its own name, index and methods, and a layer as the result still reads as its index. + assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); + assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); +} + +/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project +/// afresh in each, and an expression's own fields on it don't reach the next one. +#[test] +fn a_kept_layer_reads_the_project_again() { + let mut f = fx(); + let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; + assert_close!(f.num(first, 0.0), 21.0); + f.layer_mut(f.b).name = "Bee".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); + assert_close!(f.num("thisLayer.index", 0.0), 2.0); + let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); + assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); + f.layer_mut(f.a).name = "Ay".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); +} + +/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more +/// layers than are kept still sees one object for each layer it had kept, and the evaluations +/// after it, which start over, read the right layers. +#[test] +fn the_kept_layers_are_bounded() { + let f = fx(); + let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; + let many = + format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); + assert_close!(f.num(&many, 0.0), 11.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); +} + +/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under +/// the comp's id, before any of its layers is kept, doesn't stop them being kept. +#[test] +fn the_kept_layers_dont_go_through_object_prototype() { + let f = fx(); + let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); + assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); +} + +/// The prelude is kept from one evaluation to the next, so an expression can't change its object +/// model or internals for the ones after it. +#[test] +fn an_expression_cant_change_the_prelude_for_the_next() { + let f = fx(); + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; + let before = f.ev(f.a, ROT, read, 0.5).unwrap(); + let attacks = [ + "Comp.prototype.layer = function () { return 0; }", + "Object.getPrototypeOf(thisLayer).index = 0", + "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", + "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", + "Comp = function () {}", + "Prop = function () {}", + "thisProject = null", + "__h = function () { return null; }", + "__layer = function () { return null; }", + "__child = function () {}", + "delete __mkProp", + "delete thisLayer", + "delete thisComp", + "delete width", + "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", + ]; + for a in attacks { + let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); + assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); + } +} + mod property_group; diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs index debe5e5ac..c96045ec2 100644 --- a/crates/expr/src/tests/property_group.rs +++ b/crates/expr/src/tests/property_group.rs @@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() { assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + // The layer it reaches is the same object as `thisLayer`. + assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0); let s = staggered(); assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); } diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 24c5a3b35..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option { /// Checking these before serialization bounds host packing as well as the GPU uploads. fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> { let mut sizes = [0; 7]; - sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?; + sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?; for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() { let size = count.checked_mul(stride)?.max(16); sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?; @@ -164,11 +164,11 @@ mod capability_tests { #[test] fn packed_arrays_preflight_exact_strides_and_empty_padding() { - assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16])); - assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16])); - assert!(packed_bytes([0; 6], 159, 16, 160).is_none()); - assert!(packed_bytes([0; 6], 160, 15, 160).is_none()); - assert!(packed_bytes([2; 6], 160, 192, 191).is_none()); + assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16])); + assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16])); + assert!(packed_bytes([0; 6], 175, 16, 176).is_none()); + assert!(packed_bytes([0; 6], 176, 15, 176).is_none()); + assert!(packed_bytes([2; 6], 176, 192, 191).is_none()); for index in 0..6 { let mut counts = [0; 6]; counts[index] = u64::MAX; @@ -203,7 +203,7 @@ mod capability_tests { }; for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] { let actual = pack(&scene).map(|bytes| bytes.len() as u64); - assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160)); + assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176)); } } } @@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec; 7] { globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]); globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]); globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]); - let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 }); + let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { + radiance: 0, + mips: 1, + irradiance: 0, + intensity: 0.0, + rotation: 0.0, + sun: [0.0; 3], + key: 0.0, + shadow: -1, + shadow_darkness: 0.0, + shadow_diffusion: 0.0, + }); globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]); let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]); - globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]); + // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. + globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope, + // and its key fraction — how much of its light the shadow can remove (shade::env_shadow). + globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 645f1cc39..5a2b80f1c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -22,6 +22,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -167,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no /// readback. A region the device can't allocate leaves the buffer as it is. fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } } _ => b, }; @@ -292,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -312,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index ce7133822..510f91e8c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs index a2d2c24dd..944ca6c07 100644 --- a/crates/gpu/src/fx_sim.rs +++ b/crates/gpu/src/fx_sim.rs @@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf } let mut p = Params::default(); p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale); let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; Some(GBuf { img, ..b }) } diff --git a/crates/gpu/src/readback.rs b/crates/gpu/src/readback.rs index 0ca011350..b9d0a28a8 100644 --- a/crates/gpu/src/readback.rs +++ b/crates/gpu/src/readback.rs @@ -2,12 +2,15 @@ // Browser callbacks stay on their worker thread. The workspace's wgpu feature still // requires Send callbacks on non-atomic wasm, and ParticleSim requires Send + Sync. use std::collections::BTreeMap; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Condvar; use std::sync::{Arc, Mutex, Weak}; use std::time::Duration; pub(crate) const TIMEOUT: Duration = Duration::from_secs(10); pub(crate) type Error = String; type Done = Box, Error>) + Send>; +const TIMED_OUT: &str = "GPU readback timed out; context retired"; struct Entry { deadline: web_time::Instant, @@ -20,9 +23,70 @@ struct State { retired: Option, #[cfg(target_arch = "wasm32")] timer_armed: bool, + /// The deadline worker is to exit: the context was dropped. + #[cfg(not(target_arch = "wasm32"))] + closed: bool, + /// How many times the deadline worker woke up (tests: it must not wake while idle). + #[cfg(all(test, not(target_arch = "wasm32")))] + worker_wakes: u64, +} +/// The state and, natively, the condition the deadline worker sleeps on. The worker owns an +/// `Arc` of this, not of the [`Readbacks`], so it can block on the condition without keeping +/// the context alive; dropping the context closes it. +#[derive(Default)] +struct Inner { + state: Mutex, + #[cfg(not(target_arch = "wasm32"))] + wake: Condvar, } #[derive(Default)] -pub(crate) struct Readbacks(Mutex); +pub(crate) struct Readbacks(Arc); + +/// Retire the context when a deadline has passed: the entries to fail, or `None` when none +/// is due (or it is retired already). Decides and drains under the lock the caller holds. +fn expire_locked(s: &mut State, now: web_time::Instant) -> Option> { + // Decide and drain atomically: a cancelled old deadline must not retire + // a newer request inserted between the deadline check and retirement. + if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { + return None; + } + s.retired = Some(TIMED_OUT.to_string()); + Some(std::mem::take(&mut s.pending).into_values().collect()) +} + +/// The deadline worker. It sleeps while nothing is pending, and otherwise exactly until the +/// earliest deadline or until what is pending changes, so an idle context costs no wakeups +/// (a 100 ms polling loop here was ten wakeups a second for the life of the app). When a +/// deadline has passed it retires the context and fails every pending readback. +#[cfg(not(target_arch = "wasm32"))] +fn run_deadlines(inner: &Inner) { + let mut s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if s.closed { + return; + } + let now = web_time::Instant::now(); + match s.pending.values().map(|e| e.deadline).min() { + None => s = inner.wake.wait(s).unwrap_or_else(std::sync::PoisonError::into_inner), + Some(deadline) if deadline > now => { + s = inner.wake.wait_timeout(s, deadline - now).unwrap_or_else(std::sync::PoisonError::into_inner).0; + } + Some(_) => { + let expired = expire_locked(&mut s, now); + drop(s); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); + } + s = inner.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + continue; + } + } + #[cfg(test)] + { + s.worker_wakes += 1; + } + } +} pub(crate) struct Ticket { owner: Weak, id: u64, @@ -50,23 +114,24 @@ impl Readbacks { let me = Arc::new(Self::default()); #[cfg(not(target_arch = "wasm32"))] { - let weak = Arc::downgrade(&me); + let inner = Arc::clone(&me.0); std::thread::Builder::new() .name("gpu-readback-deadlines".into()) - .spawn(move || { - loop { - std::thread::sleep(Duration::from_millis(100)); - let Some(me) = weak.upgrade() else { break }; - me.expire(web_time::Instant::now()); - } - }) + .spawn(move || run_deadlines(&inner)) .map_err(|e| format!("starting GPU readback deadline worker: {e}"))?; } Ok(me) } + /// Have the deadline worker look at what is pending again (natively; the browser's timer + /// re-arms itself). + fn poke(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.0.wake.notify_one(); + } + pub(crate) fn check(&self) -> Result<(), Error> { - let s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match &s.retired { Some(e) => Err(e.clone()), None => Ok(()), @@ -76,7 +141,7 @@ impl Readbacks { pub(crate) fn start(self: &Arc, done: impl FnOnce(Result, Error>) + wgpu::WasmNotSend + 'static) -> Result { let mut done: Option = Some(Box::new(done)); let result = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(e) = &s.retired { Err(e.clone()) } else if s.pending.len() >= 256 { @@ -96,8 +161,10 @@ impl Readbacks { { notify(done, Err(e.clone())); } - #[cfg(target_arch = "wasm32")] if result.is_ok() { + // The worker may be asleep with nothing pending: this is its new earliest deadline. + self.poke(); + #[cfg(target_arch = "wasm32")] self.arm_timer(); } result @@ -106,7 +173,7 @@ impl Readbacks { #[cfg(target_arch = "wasm32")] fn arm_timer(self: &Arc) { let delay = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.timer_armed || s.retired.is_some() { return; } @@ -123,13 +190,14 @@ impl Readbacks { pub(crate) fn retire(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if s.retired.is_some() { return; } s.retired = Some(error.clone()); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } @@ -137,34 +205,49 @@ impl Readbacks { pub(crate) fn cancel_pending(&self, error: Error) { let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); std::mem::take(&mut s.pending) }; + self.poke(); for (_, entry) in pending { notify(entry.done, Err(error.clone())); } } + /// Retire the context when a deadline has passed (the browser's timer and tests; natively + /// the deadline worker does this itself). + #[cfg(any(test, target_arch = "wasm32"))] fn expire(&self, now: web_time::Instant) { - let error = "GPU readback timed out; context retired".to_string(); - let pending = { - let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - // Decide and drain atomically: a cancelled old deadline must not retire - // a newer request inserted between the deadline check and retirement. - if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) { - return; - } - s.retired = Some(error.clone()); - std::mem::take(&mut s.pending) + let expired = { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + expire_locked(&mut s, now) }; - for (_, entry) in pending { - notify(entry.done, Err(error.clone())); + for entry in expired.into_iter().flatten() { + notify(entry.done, Err(TIMED_OUT.into())); } } + + /// Move every pending deadline to `deadline` and wake the worker, as a test's stand-in for + /// waiting out the production timeout. + #[cfg(all(test, not(target_arch = "wasm32")))] + fn move_deadlines_for_test(&self, deadline: web_time::Instant) { + { + let mut s = self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for entry in s.pending.values_mut() { + entry.deadline = deadline; + } + } + self.poke(); + } } impl Drop for Readbacks { fn drop(&mut self) { self.retire("GPU readback context dropped".into()); + #[cfg(not(target_arch = "wasm32"))] + { + self.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).closed = true; + self.0.wake.notify_all(); + } } } @@ -194,12 +277,14 @@ impl Layout { impl Ticket { pub(crate) fn active(&self) -> bool { - self.owner.upgrade().is_some_and(|me| me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) + self.owner.upgrade().is_some_and(|me| me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id)) } pub(crate) fn complete(self, result: Result, Error>) { let Some(me) = self.owner.upgrade() else { return }; - let entry = me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); + let entry = me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id); if let Some(entry) = entry { + // Its deadline is gone: the worker sleeps until the next one, or until something new. + me.poke(); notify(entry.done, result); } } @@ -215,7 +300,7 @@ fn schedule(me: Weak, delay: Duration) -> Result<(), Error> { .map_err(|_| "GPU deadline timer unavailable".to_string())?; let callback = Closure::once(move || { if let Some(me) = me.upgrade() { - me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; + me.0.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false; me.expire(web_time::Instant::now()); // If the earliest request completed, arm for the next live deadline. me.arm_timer(); @@ -377,7 +462,7 @@ mod tests { assert_eq!(deferred.in_flight(), 0); assert_eq!(wakes.0.load(Ordering::SeqCst), 1); assert!(matches!(settled.as_mut().poll(&mut Context::from_waker(&waker)), Poll::Ready(()))); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); rx.recv_timeout(Duration::from_secs(2)).unwrap(); @@ -390,17 +475,64 @@ mod tests { tx2.send(()).unwrap(); }) .unwrap(); - { - let mut state = me.0.lock().unwrap(); - for entry in state.pending.values_mut() { - entry.deadline = web_time::Instant::now(); - } - } + me.move_deadlines_for_test(web_time::Instant::now()); rx.recv_timeout(Duration::from_secs(2)).unwrap(); - assert!(me.0.lock().unwrap().pending.is_empty()); + assert!(me.0.state.lock().unwrap().pending.is_empty()); assert!(me.check().is_err()); } + /// With nothing pending the deadline worker sleeps: no periodic wakeups (it used to poll + /// every 100 ms for the life of the app, ten wakeups a second while idle). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn an_idle_deadline_worker_does_not_wake_up() { + let me = Readbacks::new().unwrap(); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 1, "the worker woke {wakes} times while nothing was pending"); + // A readback that completes in time: the worker is told, and goes back to sleep. + let ticket = me.start(|r| assert!(r.is_ok())).unwrap(); + ticket.complete(Ok(vec![1])); + std::thread::sleep(Duration::from_millis(350)); + let wakes = me.0.state.lock().unwrap().worker_wakes; + assert!(wakes <= 3, "the worker woke {wakes} times for one completed readback"); + assert!(me.check().is_ok()); + } + + /// A deadline that passes wakes the worker exactly then, without the old polling loop. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_pending_deadline_wakes_the_worker_when_it_is_due() { + let me = Readbacks::new().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let _ticket = me + .start(move |r| { + assert!(r.is_err()); + tx.send(web_time::Instant::now()).unwrap(); + }) + .unwrap(); + let due = web_time::Instant::now() + Duration::from_millis(200); + me.move_deadlines_for_test(due); + let fired = rx.recv_timeout(Duration::from_secs(5)).expect("the deadline fires"); + assert!(fired >= due, "fired {:?} early", due - fired); + assert!(fired < due + Duration::from_secs(2), "fired {:?} late", fired - due); + assert!(me.check().is_err()); + } + + /// Dropping the last handle lets the worker exit: it holds the state, not the context. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn dropping_the_context_stops_the_deadline_worker() { + let me = Readbacks::new().unwrap(); + let inner = Arc::downgrade(&me.0); + drop(me); + let t0 = web_time::Instant::now(); + while inner.upgrade().is_some() { + assert!(t0.elapsed() < Duration::from_secs(5), "the worker is still holding the state"); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn missing_callback_times_out_once_and_rejects_late_success() { let me = Arc::new(Readbacks::default()); diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 182868423..8ac143ad0 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -14,8 +14,10 @@ struct Globals { ambient: vec4, // radiance first texture, mip count, irradiance texture, intensity env: vec4, - // rotation, any lighting (0/1), -, - + // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness env2: vec4, + // the environment's dominant direction (its shadow's source) + env_sun: vec4, }; @group(0) @binding(0) var g: Globals; @@ -339,6 +341,18 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } +// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the +// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so +// the shadow stays partial the way After Effects renders an area-light shadow. +fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { + if (g.env2.z < 0.0) { + return 1.0; + } + let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); + let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); + return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f); +} + fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { var k = 1.0; if (falloff == 1u && kind != 0u) { @@ -425,6 +439,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 0.5) { + amount = max(amount, 1.0 - env_shadow(pos, n, n)); + } return amount; } @@ -564,7 +582,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { let f0 = mix(vec3(0.04 * spec_k), rgb, metallic); let ab = env_brdf(n_v, rough); let fs = f0 * ab.x + vec3(ab.y); - acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao; + var esh = 1.0; + if (receives_shadows) { + esh = env_shadow(i.pos, ng, n); + } + acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh; } let c = acc + emissive; out.color = vec4(c * alpha, alpha); diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index d936861d3..0c6023f32 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { return FxmShade(true, vec4(rgb * a, a)); } -// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer +// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within. fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { let qx = data[at + 1u]; let qy = data[at + 2u]; @@ -180,7 +181,9 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { } let k = select(-0.7, 1.6, P.u[1].y != 0u); let nz = sqrt(max(1.0 - d2, 0.0)); - let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z); + let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w); + let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 0aaf38af0..c57c1ef84 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -498,7 +498,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { b.blend_mode = BlendMode::Difference; (a, b) }; - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let bg = s.footage(97, 61); s.push(bg); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); diff --git a/crates/gpu/src/tests/adjust.rs b/crates/gpu/src/tests/adjust.rs index 5635e08e1..39438d43e 100644 --- a/crates/gpu/src/tests/adjust.rs +++ b/crates/gpu/src/tests/adjust.rs @@ -83,7 +83,7 @@ fn track_mattes_limit_gpu_adjustments_like_the_cpu() { set(&mut matte, "transform/rotation", Value::Scalar(10.0)); let mid = s.push(matte); let c = s.p.comp_mut(s.cid).unwrap(); - let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap(); + let adj = c.layers.find_mut(|l| l.switches.adjustment).unwrap(); adj.track_matte = Some(TrackMatte { layer: mid, kind }); check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); } diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index f09798119..00b1fee14 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -5,7 +5,7 @@ //! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most //! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter. -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Gradient, Value}; use effectcraft_project::build; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid}; use effectcraft_render::three_d::adv::{self, Scene}; @@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) { } /// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light, -/// an environment light, a semi-transparent textured card. +/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent +/// textured card. pub(crate) fn scene_project() -> (Project, ItemId) { let mut p = Project::default(); let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); @@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) { set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0])); set(l, "transform/rotationY", Value::Scalar(30.0)); }); - let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 })); - add(&mut p, cid, LayerSource::Solid { item: env }, |l| { - l.environment = true; + // The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky, + // shadows the scene from above). + add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + false, + [0.0, -80.0], + [0.0, 80.0], + Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/intensity", Value::Scalar(50.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0)); }); - add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0))); add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| { set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0])); set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0])); @@ -144,6 +162,8 @@ fn gpu_matches_cpu_lit_scene() { let (p, cid) = scene_project(); let s = scene(&p, cid); assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32); + // The Environment light casts its shadow (the sky gradient's dominant direction). + assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built"); compare(&s, &g); } @@ -190,7 +210,7 @@ fn gpu_matches_cpu_wide_penumbrae() { set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); let s = scene(&p, cid); let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); - for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + for l in p.comp_mut(cid).unwrap().layers.iter_mut().take(2) { set(l, "lightOptions/castsShadows", Value::Bool(false)); } let unshadowed = adv::raster::render(&scene(&p, cid)); @@ -260,7 +280,7 @@ pub(crate) fn dof_mb_project() -> (Project, ItemId) { c.enable_motion_blur = true; c.motion_blur_samples = 6; // The sphere moves 40 px per frame. - let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); + let sphere = c.layers.find_mut(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); sphere.switches.motion_blur = true; sphere.props.prop_mut("transform/position").unwrap().keys = vec![ effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])), @@ -429,7 +449,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() { eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0); // A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU). - p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = + p.comp_mut(cid).unwrap().layers.find_mut(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = effectcraft_color::BlendMode::Screen; let (ctx, run) = run_of(&p, cid); assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none()); @@ -550,12 +570,12 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { }); } let c = p.comp_mut(cid).unwrap(); - let env = c.layers.iter_mut().find(|l| l.environment).unwrap(); + let env = c.layers.find_mut(|l| l.environment).unwrap(); env.props.sub_mut("effects").unwrap().children.push(ramp.into()); let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect(); let top = c.layers[0].id; fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer { - c.layers.iter_mut().find(|l| l.id == id).unwrap() + c.layers.find_mut(|l| l.id == id).unwrap() } match case { 0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen, @@ -573,7 +593,7 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { } _ => { // An environment background behind the run, and the 2D track matte. - c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true; + c.layers.find_mut(|l| l.environment).unwrap().environment_background = true; by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha }); } } diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 38a30aca5..7d13004ee 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -208,7 +208,7 @@ fn echo_and_posterize_time() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_sim.rs b/crates/gpu/src/tests/fx_sim.rs index dc2510bb3..4f54e07b6 100644 --- a/crates/gpu/src/tests/fx_sim.rs +++ b/crates/gpu/src/tests/fx_sim.rs @@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() { case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); } +/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the +/// refraction samples to the layer rectangle, not to the buffer (#2024). +#[test] +fn bubbles_on_padded_buffers() { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap(); + let size = [70.0, 44.0]; + for reflection in [0, 1] { + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] { + params.values.insert(k.to_string(), v); + } + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + buf.pad(6); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst); + } + } +} + #[test] fn hair_wave_world_foam() { case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); diff --git a/crates/gpu/src/tests/fx_time.rs b/crates/gpu/src/tests/fx_time.rs index 0c94bb413..c71355736 100644 --- a/crates/gpu/src/tests/fx_time.rs +++ b/crates/gpu/src/tests/fx_time.rs @@ -40,7 +40,7 @@ fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 08212a12a..40bc9483f 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -164,7 +164,7 @@ fn color_stabilizer() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; let op = a.props.prop_mut("transform/opacity").unwrap(); op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); let vals = [ ("stabilize", e(mode)), diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index a9c2e85e2..acdbea7b1 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -177,7 +177,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { let mid = s.push(m); let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); - if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + if let Some(far) = layers.find_mut(|l| l.switches.three_d && l.name == "Pattern") { far.preserve_transparency = true; } // Move the matte below the bottom so the 3D run stays one run. @@ -203,7 +203,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { a.switches.three_d = true; set(&mut a, "transform/rotationX", Value::Scalar(40.0)); let b = s.solid([0.2, 0.8, 0.4], 30, 20); - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); pre.switches.collapse = true; pre.switches.three_d = true; @@ -299,7 +299,7 @@ fn two_d_layer_with_a_three_d_track_matte() { let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; let fi = layers.iter().position(|l| l.id == fid).unwrap(); layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); - if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + if let Some(ml) = layers.find_mut(|l| l.id == mid) { ml.switches.video = false; } check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); diff --git a/crates/host/tests/it/footage_reload.rs b/crates/host/tests/it/footage_reload.rs index faf2b09e3..876c543ec 100644 --- a/crates/host/tests/it/footage_reload.rs +++ b/crates/host/tests/it/footage_reload.rs @@ -146,27 +146,58 @@ fn a_file_still_being_written_is_tried_again() { assert!(near(pixel(&s, cid, 32, 24), BLUE)); } +/// The desktop app's watcher ([`footage_reload::Watcher`]) notices a footage file another app +/// saved, hands its stamps over once, and reloads it through [`footage_reload::apply_stamps`]; +/// while nothing changes it hands over nothing (so the interface is never woken for it). #[test] -fn background_scan_reloads_changed_footage() { - let path = tmp("scan.psd"); +fn the_watcher_notices_a_footage_file_changed_on_disk() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + let path = tmp("watched.psd"); std::fs::write(&path, flat(RED, false)).unwrap(); let (mut s, _, cid) = flat_in_comp(&path); - let scan = |s: &mut Session| { - footage_reload::start_scan(s); - let t0 = std::time::Instant::now(); + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let fast = Duration::from_millis(20); + let mut w = footage_reload::Watcher::start_with( + s.services.clone(), + move || { + counter.fetch_add(1, Ordering::SeqCst); + }, + fast, + Duration::from_secs(60), + ) + .unwrap(); + w.set_focused(true); + w.set_watched(footage_reload::watched(&s)); + // Report `n`: the worker stores the stamps and then wakes the interface, so wait for the + // wake too, not only for the stamps. + let next = |w: &footage_reload::Watcher, n: usize| { + let t0 = Instant::now(); loop { - if let Some(r) = footage_reload::poll_scan(s) { - return r.unwrap(); + if wakes.load(Ordering::SeqCst) >= n + && let Some(stamps) = w.take() + { + return stamps; } - assert!(t0.elapsed().as_secs() < 10, "scan never finished"); - std::thread::yield_now(); + assert!(t0.elapsed() < Duration::from_secs(10), "the watcher never reported"); + std::thread::sleep(Duration::from_millis(5)); } }; - assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0})); + // First look: remembered, nothing to reload. + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 1)).unwrap(), json!({"files": 0, "reloaded": 0})); + assert_eq!(wakes.load(Ordering::SeqCst), 1); + std::thread::sleep(fast * 15); + assert!(w.take().is_none(), "an unchanged file is never reported"); + assert_eq!(wakes.load(Ordering::SeqCst), 1, "and never wakes the interface"); + // Saved in another app: reported once, reloaded, shown. save(&path, &flat(GREEN, true)); - assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1})); + assert_eq!(footage_reload::apply_stamps(&mut s, next(&w, 2)).unwrap(), json!({"files": 1, "reloaded": 1})); assert!(near(pixel(&s, cid, 32, 24), GREEN)); - assert!(s.footage_scan.is_none()); + assert_eq!(wakes.load(Ordering::SeqCst), 2); + std::thread::sleep(fast * 10); + assert!(w.take().is_none()); } #[test] diff --git a/crates/host/tests/it/timeline_interop.rs b/crates/host/tests/it/timeline_interop.rs index 5123dc721..9688a409f 100644 --- a/crates/host/tests/it/timeline_interop.rs +++ b/crates/host/tests/it/timeline_interop.rs @@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId { color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index 36d83dd1a..badd60b66 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -509,7 +509,7 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]); assert_eq!(c.layers[0].id.0, sid); assert_eq!(r["layers"].as_array().unwrap().len(), 2); - for l in &c.layers[1..3] { + for l in c.layers.iter().skip(1).take(2) { assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name); assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. })); } diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 56e21d578..375c1bf1b 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat return Some(format!("blend mode {}", l.blend_mode.label())); } // Premiere's Basic Motion in the interchange formats has one (uniform) scale. - if let Some(s) = l.transform().and_then(|t| t.get("scale")) - && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() > 1e-9 - }) - { + if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) { return Some("non-uniform scale".into()); } if l.preserve_transparency { @@ -581,10 +576,7 @@ impl Exporter<'_> { } } if let Some(p) = tr.get("scale") { - let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() < 1e-9 - }); + let uniform = !non_uniform_scale(p); m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); if !uniform { m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); @@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool { (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 } +/// Whether a scale property is non-uniform at some point: active key values, or the static +/// value when every key is muted (muted keys are not exported, #1894). +fn non_uniform_scale(p: &Property) -> bool { + let non_uniform = |v: &Value| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }; + if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) } +} + /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) @@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { /// A property → a FilmCraft parameter (layer-time keys → clip-time keys). fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { let mut out = fp::Param::new(conv(&p.value)); - for k in &p.keys { + // XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so + // the exported animation matches what the renderer shows (#1894). + for k in p.keys.iter().filter(|k| !k.muted) { let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); fk.interp = match k.out_interp { Interp::Linear => fp::Interpolation::Linear, @@ -684,8 +688,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option { MediaKind::AudioOnly => FootageKind::Audio, MediaKind::Still => FootageKind::Still, MediaKind::ImageSequence => FootageKind::Sequence, - _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ if v.is_none() && info.has_audio() => FootageKind::Audio, _ => FootageKind::Video, }; let _ = name; @@ -289,7 +289,7 @@ impl Importer<'_> { frame_rate: rate_in(info.frame_rate()), duration: tick_in(info.duration), has_video: kind != FootageKind::Audio, - has_audio: info.audio.is_some() || kind == FootageKind::Audio, + has_audio: info.has_audio() || kind == FootageKind::Audio, alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, codec: v.map(|v| v.codec.clone()).unwrap_or_default(), missing: true, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index fc15ce579..97efd76cd 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }) + .into_iter() + .collect(), container: String::new(), start_timecode: None, file_size: None, @@ -411,7 +415,7 @@ fn ae_project() -> (Project, ItemId) { let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None); ls.out_point = t(3.0); ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]); - c.layers = vec![lt, lp, la, ls]; + c.layers = vec![lt, lp, la, ls].into(); let cid = comp_item(&mut p, "Main", c); (p, cid) } @@ -510,6 +514,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property { l.transform().unwrap().get("position").unwrap() } +/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe +/// (the export would otherwise animate through it). +#[test] +fn export_skips_muted_keyframes() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let pos = l.transform_mut().unwrap().get_mut("position").unwrap(); + pos.keys = vec![ + Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])), + Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])), + Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])), + ]; + pos.keys[1].muted = true; + } + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap(); + let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap(); + assert_eq!(pos.keys.len(), 2, "the muted key was exported"); + assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0); + assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0); +} + +/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the +/// active keys matter, and the static value when every key is muted. +#[test] +fn muted_non_uniform_scale_does_not_force_prerendering() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let scale = l.transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))]; + scale.keys[1].muted = true; + } + let lid = l.id; + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None); + // With every key muted the static value drives evaluation, so a non-uniform static scale + // still takes the flattened path. + let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys[0].muted = true; + scale.value = Value::Vec3([50.0, 200.0, 100.0]); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale")); +} + #[test] fn export_without_prerender_leaves_text_out() { let (p, cid) = ae_project(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f0dd4849c..abf929fe9 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,6 +15,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; +use std::borrow::Borrow; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; @@ -89,12 +90,19 @@ pub struct Keyframe { /// Keyframe color label: index into the label list (0 = None). #[serde(default, skip_serializing_if = "is_zero")] pub label: u8, + /// Muted keys stay in the list (and the UI) but evaluation skips them. + #[serde(default, skip_serializing_if = "is_false")] + pub muted: bool, } fn is_zero(v: &u8) -> bool { *v == 0 } +fn is_false(v: &bool) -> bool { + !*v +} + fn yes() -> bool { true } @@ -116,6 +124,7 @@ impl Keyframe { spatial_continuous: false, roving: false, label: 0, + muted: false, } } pub fn hold(mut self) -> Keyframe { @@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] { /// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and /// key times play no part. An end key uses its one neighbour, so the path leaves the first key /// and enters the last one toward the neighbouring key instead of with no tangent. -fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { +fn auto_spatial>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i.saturating_sub(1)].value); - let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let a = v3(&keys[i.saturating_sub(1)].borrow().value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; // A side facing a key at the same position gets no tangent, so the segment between two // identical keys has zero length and the layer holds still (#1675). - let p = v3(&keys[i].value); + let p = v3(&keys[i].borrow().value); let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; (tin, tout) @@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + tangents_of(keys, i) +} + +/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]). +fn tangents_of>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { + let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) }; if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } @@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). -fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { - let k = &keys[i]; +fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease { + let k = keys[i].borrow(); let list = if out { &k.out_ease } else { &k.in_ease }; if k.auto_bezier && i > 0 && i + 1 < keys.len() { - let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time); + let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time); let slope = if spatial { - let a = v3(&keys[i - 1].value); - let c = v3(&keys[i + 1].value); + let a = v3(&keys[i - 1].borrow().value); + let c = v3(&keys[i + 1].borrow().value); ((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12) } else { - let a = keys[i - 1].value.components(); - let c = keys[i + 1].value.components(); + let a = keys[i - 1].borrow().value.components(); + let c = keys[i + 1].borrow().value.components(); (c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12) }; return Ease { speed: slope, influence: 1.0 / 6.0 }; @@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) { } /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. +/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None` +/// means every key is muted (the property's static value applies). pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { - let first = keys.first()?; + if !keys.iter().any(|k| k.muted) { + return evaluate_active(keys, t, spatial); + } + // Muting is rare: skip the muted keys by reference rather than copying every key and value. + let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect(); + evaluate_active(&active, t, spatial) +} + +fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> Option { + let first = keys.first()?.borrow(); if keys.len() == 1 || t <= first.time { return Some(first.value.clone()); } - let last = keys.last()?; + let last = keys.last()?.borrow(); if t >= last.time { return Some(last.value.clone()); } - let i = keys.partition_point(|k| k.time <= t) - 1; - Some(segment_value(keys, i, t, spatial)) + let i = keys.partition_point(|k| k.borrow().time <= t) - 1; + Some(segment_value_of(keys, i, t, spatial)) } /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { - let a = &keys[i]; - let b = &keys[i + 1]; + segment_value_of(keys, i, t, spatial) +} + +/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]). +fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value { + let a = keys[i].borrow(); + let b = keys[i + 1].borrow(); if a.out_interp == Interp::Hold || !a.value.interpolates() { return a.value.clone(); } @@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val if is_spatial { let p0 = v3(&a.value); let p3 = v3(&b.value); - let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (_, out_t) = tangents_of(keys, i); + let (in_t, _) = tangents_of(keys, i + 1); let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]); let len = seg.length(); let f = if lin_out && lin_in || len <= 1e-12 { @@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_continuous = old.spatial_continuous; k.roving = old.roving; k.label = old.label; + k.muted = old.muted; keys[i] = k; i } diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 0a31cfc19..312703239 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -395,3 +395,59 @@ proptest::proptest! { proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); } } + +#[test] +fn muted_keys_are_skipped_by_evaluation() { + let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); + assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); + keys[1].muted = true; + // The 0 → 300 segment now spans 0 → 2 s. + assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9); + assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9); + for k in &mut keys { + k.muted = true; + } + assert!(evaluate(&keys, s(0.5), false).is_none()); +} + +#[test] +fn muted_key_automation_uses_the_active_neighbours() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])), + ]; + for k in &mut keys { + k.auto_bezier = true; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + let all = keys.clone(); + let mut active = keys.clone(); + active.remove(1); + keys[1].muted = true; + for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] { + assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}"); + } + // The muted auto-Bezier key no longer bulges the motion path. + assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true)); +} + +#[test] +fn mute_round_trips_and_survives_value_replacement() { + let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0)); + let json = serde_json::to_value(&k).unwrap(); + assert!(json.get("muted").is_none(), "false is the default and is skipped"); + k.muted = true; + let json = serde_json::to_value(&k).unwrap(); + assert_eq!(json["muted"], serde_json::json!(true)); + let back: Keyframe = serde_json::from_value(json).unwrap(); + assert!(back.muted); + let mut keys = vec![k]; + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0))); + assert!(keys[0].muted); + assert_eq!(keys[0].value, Value::Scalar(2.0)); +} diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index e3264e3b7..4925460a9 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool { } /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). -/// `label` names the property in warnings. +/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and +/// the remaining keys carry the animation the renderer shows. pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { - let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) }; + let muted = p.keys.iter().any(|k| k.muted); + if muted { + ex.warn(format!("{label}: muted keyframes are not exported")); + } + let active: Vec; + let keys: &[Keyframe] = if muted { + active = p.keys.iter().filter(|k| !k.muted).cloned().collect(); + &active + } else { + &p.keys + }; + let mut out = + if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { out["x"] = json!(e.text); @@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js out } -fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec { - let keys = &p.keys; - let spatial = is_spatial(p); +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || p.hold_only { + if k.out_interp == Interp::Hold || hold_only { o["h"] = json!(1); } out.push(o); continue; } - if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() { + if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { o["h"] = json!(1); out.push(o); continue; diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 990a0f28b..5b9d29970 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -113,6 +113,9 @@ impl Out<'_, '_> { fn f(&self, m: &str) -> f64 { self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0) } + fn flag(&self, m: &str) -> bool { + self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false) + } fn animated(&self, m: &str) -> bool { self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression()) } @@ -232,7 +235,20 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve dir(&mut o); o.put("s", "size", &anim::arr2); o.put("p", "position", &anim::arr2); - o.put("r", "roundness", &anim::num); + if o.flag("perCorner") { + let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; + let radii = names.map(|m| o.f(m)); + // Lottie `rc` has one `r`: four equal static radii export as that value. + // `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number. + if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { + o.raw("r", json!({"a": 0, "k": r(radii[0])})); + } else { + o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); + o.raw("r", json!({"a": 0, "k": 0})); + } + } else { + o.put("r", "roundness", &anim::num); + } } "el" => { dir(&mut o); @@ -447,6 +463,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 31f3a70bb..83c81e242 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -878,3 +878,57 @@ fn external_images_are_local_or_skipped() { assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png")); assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings); } + +#[test] +fn muted_keys_are_omitted_from_the_export() { + let (mut p, cid) = setup(); + let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20); + let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0)); + b.muted = true; + l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))]; + push(&mut p, cid, l); + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}"); + // Lottie has no mute state: the two active keys carry the 0 → 300 animation. + assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2); + assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0])); + let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64(); + assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); + assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); +} + +#[test] +fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { + let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| { + let (mut p, cid) = setup(); + let l = shape_layer(&mut p, cid, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])] + }); + push(&mut p, cid, l); + (p, cid) + }; + let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() }; + // Four equal static radii: `rc.r` carries the value, no warning. + let (p, cid) = shape([12.0; 4], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0})); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); + // Mixed radii: not representable, roundness 0 exported with a warning. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0})); + assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); + // The checkbox off exports the single Roundness and ignores the four values. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"]["k"], json!(5.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 52a375fef..a4070297b 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -22,12 +22,13 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-aac = { workspace = true } filmcraft-isobmff = { workspace = true } +filmcraft-matroska = { workspace = true } +filmcraft-mpegts = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } -# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. -symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } diff --git a/crates/media/src/aac.rs b/crates/media/src/aac.rs new file mode 100644 index 000000000..625c37b43 --- /dev/null +++ b/crates/media/src/aac.rs @@ -0,0 +1,116 @@ +//! The AudioSpecificConfig of the AAC track FilmCraft plays from a movie, from the containers that carry +//! it outside the audio frames: MP4 / MOV (`esds`), Matroska (`CodecPrivate`) and LATM in MPEG +//! TS / PS (its StreamMuxConfig). FilmCraft's other AAC is ADTS (in MPEG TS / PS or a raw `.aac`), +//! whose channel configurations FilmCraft decodes (1 to 7) its channel count tells apart +//! ([`crate::downmix::Order::Aac`]). + +use std::io; + +use filmcraft_aac::AudioSpecificConfig; +use filmcraft_media::ByteReader; + +/// How far into an MPEG TS / PS file the audio streams are looked for: the stream is indexed as +/// far as this, not through the whole file. +const MPEG_HEAD: u64 = 8 << 20; + +/// The configuration of the audio track FilmCraft plays, if it is AAC: from an MP4 / MOV `header` +/// (its ftyp and moov, or the whole file) and `file`, or else from `file`, read as Matroska or +/// MPEG TS / PS. +pub(crate) fn config(header: Option<&[u8]>, file: Option<&dyn ByteReader>) -> Option { + let asc = match header.and_then(|h| mp4(h, file)) { + Some(asc) => asc, + None => { + let file = file?; + let mut magic = [0u8; 4]; + file.read_at(0, &mut magic).ok()?; + if magic == [0x1A, 0x45, 0xDF, 0xA3] { matroska(file)? } else { latm(file)? } + } + }; + AudioSpecificConfig::parse(&asc).ok() +} + +/// The track FilmCraft's MP4 source plays: the first audio track with samples, a sample +/// description and a timescale, read from the whole file as it is (a fragmented movie's samples +/// are in its fragments, not the `moov`). +fn mp4(header: &[u8], file: Option<&dyn ByteReader>) -> Option> { + let mp4 = match file { + Some(f) => filmcraft_isobmff::open(Head(f, f.len())), + None => filmcraft_isobmff::open(header), + }; + let mp4 = mp4.ok()?; + let playable = + |t: &&filmcraft_isobmff::Track| t.kind == filmcraft_isobmff::TrackKind::Audio && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0; + let track = mp4.tracks.iter().find(playable)?; + match &track.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.asc.clone()), + _ => None, + } +} + +/// The track FilmCraft's Matroska source plays: the first audio track with frames, found by +/// indexing the clusters as that source does. +fn matroska(file: &dyn ByteReader) -> Option> { + let mkv = filmcraft_matroska::open(Head(file, file.len())).ok()?; + match &mkv.tracks.iter().find(|t| t.kind == filmcraft_matroska::TrackKind::Audio && !t.samples.is_empty())?.codec { + filmcraft_matroska::Codec::Aac { asc } => Some(asc.clone()), + _ => None, + } +} + +/// The configuration of the stream FilmCraft's MPEG source plays: the first audio stream whose +/// first unit it can read. Only AAC is asked about, so the streams before that one were refused +/// whatever their codec, and an ADTS stream played has no configuration outside its frames. +fn latm(file: &dyn ByteReader) -> Option> { + use filmcraft_mpegts::Codec; + let head = Head(file, file.len().min(MPEG_HEAD)); + let mpeg = filmcraft_mpegts::open(&head).ok()?; + let audio = mpeg.streams.iter().enumerate().filter(|(_, s)| s.kind() == filmcraft_mpegts::Kind::Audio && !s.units.is_empty()); + let played = audio.take(filmcraft_media::MAX_AUDIO_STREAMS).find_map(|(i, s)| { + let first = mpeg.read_unit(&head, i, 0).ok()?; + match s.codec { + Codec::AacAdts => filmcraft_mpegts::frame_info(&s.codec, &first).map(|_| None), + Codec::AacLatm => filmcraft_codecs::audio::latm_config(&first).map(|c| Some(c.asc)), + _ => None, + } + }); + played? +} + +/// The first `.1` bytes of a file, as the byte source FilmCraft's demuxers read. +struct Head<'a>(&'a dyn ByteReader, u64); + +impl Head<'_> { + fn read(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + match offset.checked_add(buf.len() as u64) { + Some(end) if end <= self.1 => self.0.read_at(offset, buf), + _ => Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read past the end")), + } + } +} + +impl filmcraft_isobmff::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + +impl filmcraft_matroska::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} + +impl filmcraft_mpegts::ByteSource for Head<'_> { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read(offset, buf) + } +} diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 2ef417dd7..09bb1e1f6 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -10,16 +10,19 @@ //! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and //! stereo is untouched. //! -//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, -//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, -//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC -//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables. -//! More than 8 channels carry no known layout: only the first two (front L/R) are used. +//! A decoded channel's gains come from where it plays. WAVE / SMPTE order by channel count is what +//! FilmCraft's AC-3, PCM, FLAC and Opus readers deliver: 3 = L R C, 4 = quad L R Ls Rs, +//! 5 = L R C Ls Rs, 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. AAC +//! arrives in an order its configuration decides ([`aac_order`]). More than 8 channels in WAVE +//! order carry no known layout: only the first two (front L/R) are used. use std::f32::consts::FRAC_1_SQRT_2; +use std::sync::Arc; + +use filmcraft_aac::{AudioSpecificConfig, ProgramConfig}; /// A source channel's contribution to the left and right outputs, before normalisation. -type Pair = [f32; 2]; +pub type Pair = [f32; 2]; const FL: Pair = [1.0, 0.0]; const FR: Pair = [0.0, 1.0]; @@ -29,33 +32,18 @@ const SL: Pair = [FRAC_1_SQRT_2, 0.0]; const SR: Pair = [0.0, FRAC_1_SQRT_2]; const CS: Pair = [0.5, 0.5]; -/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised. -/// `aac` selects AAC's native channel order. Empty for zero channels. -pub fn stereo_gains(count: usize, aac: bool) -> Vec { - let raw: &[Pair] = match (count, aac) { - (0, _) => &[], - (1, _) => &[[1.0, 1.0]], - (2, _) => &[FL, FR], - (3, false) => &[FL, FR, FC], - (3, true) => &[FC, FL, FR], - (4, false) => &[FL, FR, SL, SR], - (4, true) => &[FC, FL, FR, CS], - (5, false) => &[FL, FR, FC, SL, SR], - (5, true) => &[FC, FL, FR, SL, SR], - (6, false) => &[FL, FR, FC, LFE, SL, SR], - (6, true) => &[FC, FL, FR, SL, SR, LFE], - (7, false) => &[FL, FR, FC, LFE, CS, SL, SR], - (7, true) => &[FC, FL, FR, SL, SR, CS, LFE], - (8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR], - (8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE], - _ => &[FL, FR], - }; +/// AAC channel configuration 7 as FilmCraft decodes it (element order, not reordered). +const CONFIG_7: [Pair; 8] = [FC, FL, FR, SL, SR, SL, SR, LFE]; +/// AAC channel configuration 4 (C L R Cs), which FilmCraft reorders to L R C Cs. +const CONFIG_4: [Pair; 4] = [FL, FR, FC, CS]; + +/// `raw` scaled by the reciprocal of its larger row sum when that exceeds 1 (more than two +/// channels): full-scale input can never exceed full scale. +fn normalised(raw: &[Pair]) -> Vec { let mut out = raw.to_vec(); - // Channels beyond a known layout (more than 8) are silent. - out.resize(count, [0.0, 0.0]); let peak = out.iter().map(|g| g[0]).sum::().max(out.iter().map(|g| g[1]).sum::()); // Mono is deliberately left at unity on both sides; so is anything already within range. - if count > 2 && peak > 1.0 { + if out.len() > 2 && peak > 1.0 { for g in &mut out { g[0] /= peak; g[1] /= peak; @@ -64,11 +52,109 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } -/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a -/// channel is too short for count as silence, extra frames in `out` stay as they are. -pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = stereo_gains(channels.len(), aac); - for (ch, g) in channels.iter().zip(&gains) { +/// The per-channel `[left, right]` gains for `count` channels in WAVE / SMPTE order, normalised. +/// Empty for zero channels. +pub fn stereo_gains(count: usize) -> Vec { + let raw: &[Pair] = match count { + 0 => &[], + 1 => &[[1.0, 1.0]], + 2 => &[FL, FR], + 3 => &[FL, FR, FC], + 4 => &[FL, FR, SL, SR], + 5 => &[FL, FR, FC, SL, SR], + 6 => &[FL, FR, FC, LFE, SL, SR], + 7 => &[FL, FR, FC, LFE, CS, SL, SR], + 8 => &[FL, FR, FC, LFE, SL, SR, SL, SR], + _ => &[FL, FR], + }; + let mut raw = raw.to_vec(); + // Channels beyond a known layout (more than 8) are silent. + raw.resize(count, [0.0, 0.0]); + normalised(&raw) +} + +/// Where a source's decoded channels play. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Order { + /// WAVE / SMPTE by channel count: PCM, FLAC, AC-3, Opus and the rest. + #[default] + Wav, + /// AAC whose configuration wasn't read: by channel count as the configurations 1 to 7 decode + /// (exact for ADTS, which can signal no other configuration FilmCraft decodes), 7 channels as + /// configuration 11. + Aac, + /// Each decoded channel's gains, before normalisation. + Speakers(Arc<[Pair]>), +} + +/// How FilmCraft's decoder hands over AAC with the configuration `asc`: it reorders channel +/// configurations 3 to 6, 11 and 12 to WAVE / SMPTE and leaves 7 and PCE layouts in element order. +pub fn aac_order(asc: &AudioSpecificConfig) -> Order { + match (asc.channel_config, &asc.pce) { + (4, _) => Order::Speakers(CONFIG_4.into()), + (1..=6 | 11 | 12, _) => Order::Wav, + (7, _) => Order::Speakers(CONFIG_7.into()), + (0, Some(pce)) => Order::Speakers(pce_speakers(pce).into()), + _ => Order::Aac, + } +} + +/// ffmpeg's encoder fills a program config element's elements with its layout's channels in its +/// own order (L R C LFE …), and for these structures that order doesn't match the element +/// positions: an LFE in a side SCE, a back centre in a side pair. Front, side and back element +/// types (`true` for a pair) and the channels of the ffmpeg layout with that structure (the first +/// ffmpeg 8.1 lists, where several share it: 6.1 for 6.1(back) and 6.1(front), 7.1(wide) for +/// 7.1(wide-side)). +const FFMPEG_PCE: [(&[bool], &[bool], &[bool], &[Pair]); 6] = [ + (&[true, false], &[false], &[false], &[FL, FR, FC, LFE, CS]), + (&[true, false], &[false], &[true], &[FL, FR, FC, LFE, SL, SR]), + (&[true, false], &[true], &[false], &[FL, FR, FC, CS, SL, SR]), + (&[true, false], &[false], &[true, false], &[FL, FR, FC, LFE, CS, SL, SR]), + (&[true, false], &[false], &[true, true], &[FL, FR, FC, LFE, SL, SR, FL, FR]), + (&[true, false], &[true], &[true, false], &[FL, FR, FC, SL, SR, CS, SL, SR]), +]; + +/// The channels of a PCE layout in decoded (element) order: ffmpeg's for its structures above +/// (it names itself in the PCE's comment), otherwise by position (ISO/IEC 14496-3's program config element): +/// front pairs left and right, a front single the centre, side and back pairs surrounds, a side +/// or back single a back centre. +fn pce_speakers(pce: &ProgramConfig) -> Vec { + let pairs = |els: &[(bool, u8)]| els.iter().map(|e| e.0).collect::>(); + let (front, side, back) = (pairs(&pce.front), pairs(&pce.side), pairs(&pce.back)); + if pce.comment.starts_with(b"Lavc") && pce.lfe.is_empty() { + let known = FFMPEG_PCE.iter().find(|(f, s, b, _)| *f == front.as_slice() && *s == side.as_slice() && *b == back.as_slice()); + if let Some((.., speakers)) = known { + return speakers.to_vec(); + } + } + let mut out = Vec::new(); + for pair in front { + out.extend_from_slice(if pair { &[FL, FR] } else { &[FC] }); + } + for pair in side.into_iter().chain(back) { + out.extend_from_slice(if pair { &[SL, SR] } else { &[CS] }); + } + out.extend(pce.lfe.iter().map(|_| LFE)); + out +} + +/// The gains for `count` channels arriving in `order`. FilmCraft decodes as many channels as the +/// container names, the first ones of the layout where that is fewer (MPEG-TS LATM names 7 of +/// configuration 7's 8, ffmpeg 4.2 MP4s 2 of a PCE's); more go by count. +pub fn decoded_gains(count: usize, order: &Order) -> Vec { + match order { + Order::Speakers(s) if count <= s.len() => normalised(s.get(..count).unwrap_or_default()), + Order::Aac if count == 4 => normalised(&CONFIG_4), + Order::Aac if count == 8 => normalised(&CONFIG_7), + _ => stereo_gains(count), + } +} + +/// Downmix planar `channels` arriving in `order` to interleaved stereo in `out` (`out.len() / 2` +/// frames); frames a channel is too short for count as silence, extra frames in `out` stay as +/// they are. +pub fn downmix_into(channels: &[Vec], order: &Order, out: &mut [f32]) { + for (ch, g) in channels.iter().zip(&decoded_gains(channels.len(), order)) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -85,49 +171,105 @@ mod tests { #[test] fn every_channel_count_has_a_bounded_defined_matrix() { - for aac in [false, true] { + let pce = Order::Speakers([FL, FR, FC, FC, SL, SR, SL, SR, CS, LFE].into()); + for order in [Order::Wav, Order::Aac, Order::Speakers(CONFIG_7.into()), pce] { for n in 0..40usize { - let g = stereo_gains(n, aac); + let g = decoded_gains(n, &order); assert_eq!(g.len(), n); let (l, r) = (g.iter().map(|p| p[0]).sum::(), g.iter().map(|p| p[1]).sum::()); if n > 2 { - assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}"); + assert!(l <= 1.0001 && r <= 1.0001, "{n} channels ({order:?}): {l} {r}"); assert!(l > 0.0 && r > 0.0); } } } } + /// AudioSpecificConfigs as ffmpeg 8.1 and FilmCraft's encoder write them (48 kHz AAC-LC). + const PCE_FFMPEG_61: &str = "118004c848002000c4400d4c61766336322e32382e31303256e500"; + const PCE_FILMCRAFT_61: &str = "118004c845000108840000"; + + fn order(hex: &str) -> Order { + let asc: Vec = (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|b| u8::from_str_radix(b, 16).ok())).collect(); + aac_order(&AudioSpecificConfig::parse(&asc).expect("an AudioSpecificConfig")) + } + + fn speakers(order: &Order) -> Vec { + match order { + Order::Speakers(s) => s.to_vec(), + o => panic!("{o:?} names no speakers"), + } + } + + #[test] + fn aac_order_follows_the_channel_configuration() { + for n in [1, 2, 3, 5, 6, 11, 12] { + assert_eq!(aac_order(&AudioSpecificConfig::lc(3, n)), Order::Wav, "configuration {n}"); + } + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 4))), [FL, FR, FC, CS]); + assert_eq!(speakers(&aac_order(&AudioSpecificConfig::lc(3, 7))), [FC, FL, FR, SL, SR, SL, SR, LFE]); + assert_eq!(speakers(&order(PCE_FFMPEG_61)), [FL, FR, FC, LFE, CS, SL, SR], "ffmpeg's 6.1 carries the LFE in a side SCE"); + assert_eq!(speakers(&order(PCE_FILMCRAFT_61)), [FC, FL, FR, SL, SR, CS, LFE], "a centre-first PCE by position"); + } + + #[test] + fn only_ffmpeg_pce_structures_it_misplaces_take_its_channel_order() { + let pce = |comment: &[u8], side: Vec<(bool, u8)>, lfe: Vec| ProgramConfig { + front: vec![(true, 0), (false, 0)], + side, + back: vec![(true, 1)], + lfe, + comment: comment.to_vec(), + ..Default::default() + }; + // 5.1(side): ffmpeg's LFE sits in the side SCE + assert_eq!(pce_speakers(&pce(b"Lavc58.54.100", vec![(false, 1)], vec![])), [FL, FR, FC, LFE, SL, SR]); + // the same structure from another encoder: a back centre by position + assert_eq!(pce_speakers(&pce(b"", vec![(false, 1)], vec![])), [FL, FR, FC, CS, SL, SR]); + // 7.0: ffmpeg's order matches the positions + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(true, 2)], vec![])), [FL, FR, FC, SL, SR, SL, SR]); + // a real LFE element is never ffmpeg's misplaced one + assert_eq!(pce_speakers(&pce(b"Lavc62.28.102", vec![(false, 1)], vec![0])), [FL, FR, FC, CS, SL, SR, LFE]); + } + + #[test] + fn a_decode_of_fewer_channels_takes_the_first_of_the_layout_and_more_go_by_count() { + assert_eq!(decoded_gains(2, &order(PCE_FILMCRAFT_61)), [FC, FL]); + assert_eq!(decoded_gains(7, &Order::Speakers(CONFIG_7.into())), normalised(&CONFIG_7[..7])); + assert_eq!(decoded_gains(9, &Order::Speakers(CONFIG_7.into())), stereo_gains(9)); + assert_eq!(decoded_gains(4, &Order::Aac), normalised(&[FL, FR, FC, CS])); + assert_eq!(decoded_gains(8, &Order::Aac), normalised(&CONFIG_7)); + for n in [3, 5, 6, 7] { + assert_eq!(decoded_gains(n, &Order::Aac), stereo_gains(n), "{n} channels"); + } + } + #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); - let g = stereo_gains(6, false); + let g = stereo_gains(6); assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0); assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6); assert_eq!(g[3], [0.0, 0.0], "LFE is left out"); assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0); assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0); - // AAC order: the centre is first, LFE last - let a = stereo_gains(6, true); - assert_eq!((a[0], a[5]), (g[2], g[3])); - assert_eq!((a[1], a[2]), (g[0], g[1])); } #[test] fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() { - assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]); - assert_eq!(stereo_gains(2, true), vec![FL, FR]); - let g = stereo_gains(12, false); + assert_eq!(stereo_gains(1), vec![[1.0, 1.0]]); + assert_eq!(stereo_gains(2), vec![FL, FR]); + let g = stereo_gains(12); assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0])); } #[test] fn ragged_and_empty_input_never_panics() { let mut out = vec![0.0; 8]; - downmix_into(&[], false, &mut out); - downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out); + downmix_into(&[], &Order::Wav, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], &Order::Aac, &mut out); assert!(out.iter().all(|v| v.is_finite())); let mut none: [f32; 0] = []; - downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none); + downmix_into(&vec![vec![1.0; 3]; 7], &Order::Speakers(CONFIG_7.into()), &mut none); } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 946e0d12a..e985985b7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -15,6 +15,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod aac; mod convert; pub mod downmix; mod dpx; @@ -64,8 +65,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; -/// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 1f1587059..655ca4ed8 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -12,6 +12,7 @@ use filmcraft_media::{FrameRequest, SharedSource}; use image::metadata::Orientation; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; +use crate::downmix::Order; use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; @@ -178,10 +179,36 @@ struct CacheState { last: HashMap, i64>, } -/// A movie/audio source by path and the turn its picture is shown with -/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it -/// (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +/// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait +/// for it (`None`: failed to open; not retried until `forget`). +type SourceSlot = Arc>>; + +/// An opened movie/audio source, the turn its picture is shown with and the order its decoded +/// audio channels arrive in. +#[derive(Clone)] +struct Opened { + src: SharedSource, + turn: Orientation, + order: Order, +} + +impl Opened { + /// `src` with what its container says: `header` is an MP4/MOV's ftyp and moov (or the whole + /// file), `file` the file for other containers' AAC configuration. + fn new(src: SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Opened { + let turn = header.map_or(Orientation::NoTransforms, crate::probe::video_turn); + Opened { order: channel_order(&src, header, file), src, turn } + } +} + +/// The order `src`'s audio channels decode in; multichannel AAC's by its configuration, where +/// the container's header shows it ([`crate::aac::config`]). +fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn filmcraft_media::ByteReader>) -> Order { + match src.info().audio() { + Some(a) if a.codec.contains("AAC") && a.channels > 2 => crate::aac::config(header, file).map_or(Order::Aac, |asc| crate::downmix::aac_order(&asc)), + _ => Order::Wav, + } +} /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). type GifSlot = Arc, String>>>; @@ -514,23 +541,23 @@ impl MediaPool { /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let src = self.inner.source(&path).ok_or("the file can't be opened")?; + let Opened { src, order, .. } = self.inner.opened(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); - let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + let native = src.info().audio().map_or(rate, |a| a.sample_rate); // The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about // 47 GB (#2141). Real audio is 1–768 kHz. if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) { return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); } if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, s0, frames, rate); + return read_stereo(&src, &order, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; - let buf = read_stereo(&src, first64, count, native)?; + let buf = read_stereo(&src, &order, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -593,17 +620,12 @@ impl Inner { crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } - /// The opened movie/audio source for `path`. The first caller opens it, outside the map's - /// lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF files are - /// decoded whole); callers asking for the same file meanwhile wait for that instead of opening - /// it again: a preview's frame workers, audio feeder, conform and waveform threads each - /// decoded a long FLAC in full at once (#408). - fn source(&self, path: &Arc) -> Option { - self.opened(path).map(|(src, _)| src) - } - - /// [`Self::source`] and the turn the movie's picture is shown with. - fn opened(&self, path: &Arc) -> Option<(SharedSource, Orientation)> { + /// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside + /// the map's lock (reading and parsing a large file takes a moment; FLAC, MP3, Ogg and AIFF + /// files are decoded whole); callers asking for the same file meanwhile wait for that instead + /// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads + /// each decoded a long FLAC in full at once (#408). + fn opened(&self, path: &Arc) -> Option { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); @@ -613,14 +635,14 @@ impl Inner { let file = std::path::Path::new(&**path); let in_place = if in_memory { None } else { crate::stream::open_source(file) }; let in_place = in_place.map(|src| { - let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); - src.map(|src| (src, turn)) + let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file)); + src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())) }); let opened = in_place.unwrap_or_else(|| { self.read(path).and_then(|bytes| { let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let turn = crate::probe::video_turn(&bytes); - filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) + let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?; + Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone())))) }) }); match opened { @@ -712,9 +734,12 @@ impl Inner { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); let i = Self::frame_index(rate, t, n, footage.loop_count); - // Middle of the frame in the file's own timing (robust to timestamp rounding). + // A quarter into the frame in the file's own timing: FilmCraft rounds a request to + // the track's nearest timestamp unit (halves up), so the middle of a frame would + // land on the next one when the timescale is the frame rate; a quarter still + // tolerates timestamps rounded either way. let native = footage.native_rate.unwrap_or(rate); - let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); + let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 4); Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } @@ -851,7 +876,7 @@ impl Inner { Ok(dynamic_to_image(frame, op)) } Some(mt) => { - let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; + let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; let vf = src .video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))) .map_err(MediaError::from) @@ -950,8 +975,8 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. -fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { +/// channels, multichannel sources (channels in `order`) are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, order: &Order, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { @@ -959,8 +984,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); - crate::downmix::downmix_into(&buf.channels, aac, dst); + crate::downmix::downmix_into(&buf.channels, order, dst); Ok(out) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 41b50b0f4..6bae86f5b 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -175,7 +175,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - (_, None) => FootageKind::Audio, }; let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate)); - let codec = match (v, info.audio.as_ref()) { + let codec = match (v, info.audio()) { (Some(v), _) => v.codec.clone(), (None, Some(a)) => a.codec.clone(), _ => String::new(), @@ -190,7 +190,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - native_rate: None, duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) }, has_video: v.is_some(), - has_audio: info.audio.is_some(), + has_audio: info.has_audio(), alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, premul_color: [0.0; 3], loop_count: 1, @@ -295,12 +295,19 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other -/// containers have none. +/// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this +/// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; + // A pure rotation is already applied by FilmCraft's decoder (frames and size come out + // upright); only the headers it leaves as stored (mirrored, scaled, sheared) are turned here. + if track.display_rotation().is_some() { + return Orientation::NoTransforms; + } + let m = track.matrix; // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. let (a, b) = (m[0] as i64, m[1] as i64); match (a.abs() >= b.abs(), a >= 0, b >= 0) { diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 447e239d1..80db21fc4 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -85,6 +85,17 @@ pub(crate) fn open_source(_path: &Path) -> Option> { None } +/// `path` read in place (`None` when it can't be opened, or without a native file system). +#[cfg(any(unix, windows))] +pub(crate) fn file_reader(path: &Path) -> Option> { + Some(Box::new(FileReader::open(path).ok()?)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn file_reader(_path: &Path) -> Option> { + None +} + /// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display /// matrix needs), without reading the samples: top-level box headers are walked with positional /// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large. diff --git a/crates/media/tests/it/aac_layout.rs b/crates/media/tests/it/aac_layout.rs new file mode 100644 index 000000000..fb3e078a6 --- /dev/null +++ b/crates/media/tests/it/aac_layout.rs @@ -0,0 +1,422 @@ +//! Multichannel AAC reaches stereo by where each channel plays, whatever order its channel +//! configuration decodes in (#1701). Fixtures are tones encoded with FilmCraft's AAC encoder (no +//! ffmpeg needed, so nothing is skipped), with the configuration rewritten where the coded +//! elements fit another: configurations 11 and 12 code the same elements as the encoder's 7- and +//! 8-channel output, and a program config element can name any of them. + +use std::f64::consts::PI; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe, probe_bytes}; +use effectcraft_time::Tick; +use filmcraft_aac::{AudioSpecificConfig, Encoder, EncoderConfig, ProgramConfig}; + +const RATE: u32 = 48_000; +/// Tone frequencies by channel; an LFE gets [`LFE_TONE`], which its element can carry. +const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700]; +const LFE_TONE: u32 = 60; +const AMP: f32 = 0.25; +const K: f64 = std::f64::consts::FRAC_1_SQRT_2; + +/// A channel's content, by where it plays. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ch { + L, + R, + C, + Lfe, + Ls, + Rs, + Cs, +} + +impl Ch { + /// ITU-R BS.775 gains into `[left, right]`, before normalisation. + fn gains(self) -> [f64; 2] { + match self { + Ch::L => [1.0, 0.0], + Ch::R => [0.0, 1.0], + Ch::C => [K, K], + Ch::Lfe => [0.0, 0.0], + Ch::Ls => [K, 0.0], + Ch::Rs => [0.0, K], + Ch::Cs => [0.5, 0.5], + } + } +} + +struct Case { + name: &'static str, + /// What the encoder's input channels carry (its element order: C, L R, …, LFE last). + content: &'static [Ch], + /// The AudioSpecificConfig written to the container instead of the encoder's, if any. + asc: Option, +} + +fn lc(config: u8) -> Option { + Some(AudioSpecificConfig::lc(3, config)) +} + +fn pce(front: Vec<(bool, u8)>, side: Vec<(bool, u8)>, back: Vec<(bool, u8)>, lfe: Vec, comment: &[u8]) -> Option { + let pce = ProgramConfig { object_type: 1, sf_index: 3, front, side, back, lfe, comment: comment.to_vec(), ..Default::default() }; + Some(AudioSpecificConfig { pce: Some(pce), ..AudioSpecificConfig::lc(3, 0) }) +} + +use Ch::*; + +fn cases() -> Vec { + vec![ + Case { name: "config3", content: &[C, L, R], asc: None }, + Case { name: "config4", content: &[C, L, R, Cs], asc: None }, + Case { name: "config5", content: &[C, L, R, Ls, Rs], asc: None }, + Case { name: "config6", content: &[C, L, R, Ls, Rs, Lfe], asc: None }, + // configuration 7 as ffmpeg writes 7.1: the second and third pairs are surrounds + Case { name: "config7", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: None }, + Case { name: "config11", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: lc(11) }, + Case { name: "config12", content: &[C, L, R, Ls, Rs, Ls, Rs, Lfe], asc: lc(12) }, + // the encoder's own 6.1: a PCE listing the centre first + Case { name: "pce61-centre-first", content: &[C, L, R, Ls, Rs, Cs, Lfe], asc: None }, + // ffmpeg's 6.1 PCE: front pair and centre, a side SCE, a back pair and SCE, no LFE + // element, its channels in ffmpeg's order L R C LFE Cs Ls Rs. The decoder places the + // encoder's SCE 0 in the front SCE, pair 0 front, pair 1 back, SCE 1 in the back SCE (tag + // 1) and the LFE in the side SCE left over (tag 5). + Case { + name: "pce61-ffmpeg", + content: &[C, L, R, Cs, Ls, Rs, Lfe], + asc: pce(vec![(true, 0), (false, 0)], vec![(false, 5)], vec![(true, 1), (false, 1)], vec![], b"Lavc62.28.102"), + }, + ] +} + +fn freq(content: &[Ch], i: usize) -> u32 { + if content.get(i) == Some(&Lfe) { LFE_TONE } else { FREQS[i] } +} + +/// Two seconds of `content`'s tones encoded as AAC: the AudioSpecificConfig and access units. +fn encode(content: &[Ch], adts: bool) -> (Vec, Vec>) { + let n = content.len(); + let mut cfg = EncoderConfig::cbr(RATE, n, 96_000 * n as u32); + cfg.adts = adts; + let mut enc = Encoder::new(cfg).expect("encoder"); + let planes: Vec> = + (0..n).map(|c| (0..2 * RATE).map(|i| AMP * (2.0 * PI * f64::from(freq(content, c)) * f64::from(i) / f64::from(RATE)).sin() as f32).collect()).collect(); + let refs: Vec<&[f32]> = planes.iter().map(Vec::as_slice).collect(); + let mut units = enc.encode(&refs); + units.extend(enc.flush()); + (enc.audio_specific_config(), units) +} + +fn mp4(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mp4_after(None, asc, channels, units) +} + +/// As [`mp4`], after an audio track with the configuration and channel count `empty` and no +/// samples, if any. +fn mp4_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); + if let Some((asc, channels)) = empty { + w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("empty track"); + } + let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).expect("track"); + for u in units { + w.write_sample(t, WriteSample { data: u, duration: 1024, composition_offset: 0, is_sync: true }).expect("sample"); + } + w.finish().expect("finish").into_inner() +} + +fn mkv(asc: Vec, channels: u32, units: &[Vec]) -> Vec { + mkv_after(None, asc, channels, units) +} + +/// As [`mkv`], after an audio track with the configuration and channel count `empty` and no +/// frames, if any. +fn mkv_after(empty: Option<(Vec, u32)>, asc: Vec, channels: u32, units: &[Vec]) -> Vec { + use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + let track = |asc: Vec, channels: u32| { + let mut t = TrackSpec::new(TrackKind::Audio, "A_AAC"); + t.codec_private = asc; + t.audio = Some((f64::from(RATE), u64::from(channels), None)); + t + }; + let tracks: Vec = empty.into_iter().map(|(asc, n)| track(asc, n)).chain([track(asc, channels)]).collect(); + let played = tracks.len() - 1; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), tracks, MuxOptions::default()).expect("writer"); + let frame_ns = 1_000_000_000 * 1024 / i64::from(RATE); + for (i, u) in units.iter().enumerate() { + w.write_frame(played, i as i64 * frame_ns, true, u, Some(frame_ns as u64)).expect("frame"); + } + w.finish().expect("finish").into_inner() +} + +/// The fixture written to disk as `name`. +fn write(name: &str, bytes: &[u8]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("effectcraft-aac-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(name); + std::fs::write(&p, bytes).expect("write"); + p +} + +/// Amplitude of the tone at `freq` in one side of interleaved stereo. +fn amp(s: &[f32], side: usize, freq: u32) -> f64 { + let (mut re, mut im, mut n) = (0.0, 0.0, 0usize); + for (i, f) in s.as_chunks::<2>().0.iter().enumerate() { + let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE); + re += f64::from(f[side]) * w.cos(); + im += f64::from(f[side]) * w.sin(); + n += 1; + } + 2.0 * (re * re + im * im).sqrt() / n.max(1) as f64 +} + +/// Stereo of one second from 0.5 s in, through the media pool as footage reads it. +fn mix(pool: &MediaPool, footage: &effectcraft_project::Footage) -> Vec { + pool.audio_samples(footage, Tick::from_seconds_f64(0.5), RATE as usize, RATE) +} + +/// Each channel of `content` reaches each side at its BS.775 gain, normalised by the larger +/// side's sum (an LFE nowhere). +fn check(label: &str, content: &[Ch], s: &[f32]) { + let raw: Vec<[f64; 2]> = content.iter().map(|c| c.gains()).collect(); + let norm = raw.iter().map(|g| g[0]).sum::().max(raw.iter().map(|g| g[1]).sum::()).max(1.0); + for (i, (ch, g)) in content.iter().zip(&raw).enumerate() { + for side in 0..2 { + let (got, want) = (amp(s, side, freq(content, i)), f64::from(AMP) * g[side] / norm); + assert!( + (got - want).abs() <= f64::from(AMP) * 0.05, + "{label}: {ch:?} ({} Hz) -> {} is {got:.4}, expected {want:.4}", + freq(content, i), + ["L", "R"][side] + ); + } + } +} + +/// The LFE tone survives encoding, so its absence from the mix means it was left out. +fn lfe_is_coded(case: &Case, bytes: Vec) { + if !case.content.contains(&Lfe) { + return; + } + let src = filmcraft_codecs::open_bytes("lfe.mp4", bytes.into()).expect("open"); + let buf = src.audio(24_000, RATE as usize, RATE).expect("decode"); + let best = buf.channels.iter().map(|ch| amp(&ch.iter().flat_map(|&v| [v, v]).collect::>(), 0, LFE_TONE)).fold(0.0, f64::max); + assert!(best > 0.8 * f64::from(AMP), "{}: the LFE tone is coded ({best:.4})", case.name); +} + +#[test] +fn every_aac_layout_mixes_by_speaker_in_mp4() { + for case in cases() { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let bytes = mp4(asc, case.content.len() as u32, &units); + lfe_is_coded(&case, bytes.clone()); + let p = write(&format!("{}.mp4", case.name), &bytes); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + // in memory (`add_bytes`) as well as in place + let (path, bytes): (String, Arc<[u8]>) = (format!("/mem/{}.mp4", case.name), bytes.into()); + let pool = MediaPool::new(); + pool.add_bytes(&path, bytes.clone()); + check(&format!("{} in memory", case.name), case.content, &mix(&pool, &probe_bytes(&path, bytes).expect("probe"))); + } +} + +#[test] +fn matroska_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| matches!(c.name, "config12" | "pce61-centre-first" | "pce61-ffmpeg")) { + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{}.mkv", case.name), &mkv(asc, case.content.len() as u32, &units)); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// ADTS signals configurations 1 to 7 only, which the channel count tells apart. +#[test] +fn adts_aac_layouts_mix_by_speaker() { + for case in cases().into_iter().filter(|c| c.asc.is_none() && c.content.len() != 7) { + let (_, units) = encode(case.content, true); + let p = write(&format!("{}.aac", case.name), &units.concat()); + check(case.name, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// Audio plays from the first track FilmCraft can play, so an audio track with no samples before +/// it lends it no configuration. Each case decodes in another order than the empty track's +/// configuration would (FilmCraft reorders 12 to WAVE / SMPTE but not 7, and 11 but not a PCE). +#[test] +fn an_empty_audio_track_lends_its_configuration_to_no_other() { + for (name, empty) in [("config12", 7), ("pce61-centre-first", 11)] { + let case = cases().into_iter().find(|c| c.name == name).expect("case"); + let n = case.content.len() as u32; + let empty = Some((AudioSpecificConfig::lc(3, empty).to_bytes(), n)); + let (asc, units) = encode(case.content, false); + let asc = case.asc.as_ref().map_or(asc, AudioSpecificConfig::to_bytes); + let p = write(&format!("{name}-after-empty.mp4"), &mp4_after(empty.clone(), asc.clone(), n, &units)); + check(&format!("{name} MP4"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + let p = write(&format!("{name}-after-empty.mkv"), &mkv_after(empty, asc, n, &units)); + check(&format!("{name} Matroska"), case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// A transport stream plays its first audio stream and mixes by that stream's configuration, +/// whatever follows it. ADTS of configuration 7 decodes in element order, LATM of configuration +/// 12 reordered to WAVE / SMPTE; LATM of configuration 7 names 7 channels, which mix as +/// configuration 7's rather than as 7 channels by count. (FilmCraft decodes LATM of configuration +/// 12 as 12 channels, so it isn't played here.) +#[test] +fn mpeg_aac_mixes_by_the_stream_played() { + let case = cases().into_iter().find(|c| c.name == "config7").expect("case"); + let (_, adts) = encode(case.content, true); + let (_, raw) = encode(case.content, false); + for (label, latm_first, latm_config) in [("ADTS before LATM", false, 12), ("LATM before ADTS", true, 7)] { + let (adts_pid, latm_pid) = if latm_first { (0x102, 0x101) } else { (0x101, 0x102) }; + let mut ts = Ts::default(); + let mut streams = [(0x0F, adts_pid), (0x11, latm_pid)]; + streams.sort_by_key(|s| s.1); + ts.psi(&streams); + for (i, (a, r)) in adts.iter().zip(&raw).enumerate() { + let pts = 1920 * i as i64; + let mut units = [(adts_pid, pes(pts, a)), (latm_pid, pes(pts, &loas(latm_config, r)))]; + units.sort_by_key(|u| u.0); + for (pid, unit) in units { + ts.pes(pid, &unit); + } + } + let p = write(&format!("latm-first-{latm_first}.ts"), &ts.out); + check(label, case.content, &mix(&MediaPool::new(), &probe(&p).expect("probe"))); + } +} + +/// A LOAS frame of `payload` with its StreamMuxConfig: AAC LC, 48 kHz, channel configuration +/// `config`. +fn loas(config: u32, payload: &[u8]) -> Vec { + let mut bits = Bits::default(); + // useSameStreamMux, audioMuxVersion, allStreamsSameTimeFraming, numSubFrames, numProgram, numLayer + bits.put(0b001, 3); + bits.put(0, 13); + // AudioSpecificConfig and GASpecificConfig + bits.put(2, 5); + bits.put(3, 4); + bits.put(config, 4); + bits.put(0, 3); + // frameLengthType, latmBufferFullness, otherDataPresent, crcCheckPresent + bits.put(0, 3); + bits.put(0xFF, 8); + bits.put(0, 2); + let mut len = payload.len(); + while len >= 255 { + bits.put(255, 8); + len -= 255; + } + bits.put(len as u32, 8); + for &b in payload { + bits.put(u32::from(b), 8); + } + let body = bits.finish(); + let mut v = vec![0x56, 0xE0 | (body.len() >> 8) as u8, body.len() as u8]; + v.extend(body); + v +} + +/// A big-endian bit writer. +#[derive(Default)] +struct Bits { + out: Vec, + acc: u64, + n: u32, +} + +impl Bits { + fn put(&mut self, v: u32, n: u32) { + self.acc = self.acc << n | u64::from(v); + self.n += n; + while self.n >= 8 { + self.n -= 8; + self.out.push((self.acc >> self.n) as u8); + } + } + fn finish(mut self) -> Vec { + if self.n > 0 { + self.put(0, 8 - self.n); + } + self.out + } +} + +/// An audio PES packet (stream id 0xC0) presented at `pts` (90 kHz). +fn pes(pts: i64, payload: &[u8]) -> Vec { + let t = [0x21 | ((pts >> 29) & 0x0E) as u8, (pts >> 22) as u8, ((pts >> 14) & 0xFE) as u8 | 1, (pts >> 7) as u8, ((pts << 1) & 0xFE) as u8 | 1]; + let len = 8 + payload.len(); + let mut v = vec![0, 0, 1, 0xC0, (len >> 8) as u8, len as u8, 0x80, 0x80, 5]; + v.extend(t); + v.extend_from_slice(payload); + v +} + +/// A transport stream of one program (its PMT on PID 0x100), PES cut into 188-byte packets. +#[derive(Default)] +struct Ts { + out: Vec, + cc: std::collections::HashMap, +} + +impl Ts { + fn packet(&mut self, pid: u16, start: bool, payload: &[u8]) -> usize { + let cc = self.cc.entry(pid).or_insert(0); + let mut p = vec![0x47, u8::from(start) << 6 | (pid >> 8) as u8, pid as u8]; + let n = payload.len().min(184); + if n < 184 { + let stuffing = 183 - n; + p.extend([0x30 | *cc, stuffing as u8]); + if stuffing > 0 { + p.push(0); + p.extend(std::iter::repeat_n(0xFF, stuffing - 1)); + } + } else { + p.push(0x10 | *cc); + } + *cc = (*cc + 1) & 15; + p.extend_from_slice(&payload[..n]); + self.out.extend(p); + n + } + fn section(&mut self, pid: u16, body: &[u8]) { + let mut s = vec![0]; + s.extend_from_slice(body); + s.extend(crc32_mpeg(body).to_be_bytes()); + self.packet(pid, true, &s); + } + /// The PAT and a PMT of `streams` (stream type, PID). + fn psi(&mut self, streams: &[(u8, u16)]) { + self.section(0, &[0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00]); + let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x01, 0xF0, 0]; + for &(kind, pid) in streams { + pmt.extend([kind, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]); + } + pmt[2] = (pmt.len() + 1) as u8; + self.section(0x100, &pmt); + } + fn pes(&mut self, pid: u16, data: &[u8]) { + let (mut rest, mut start) = (data, true); + while !rest.is_empty() { + let n = self.packet(pid, start, rest); + rest = &rest[n..]; + start = false; + } + } +} + +/// CRC-32 of PSI sections (MSB first, polynomial 0x04C11DB7, initial value all ones). +fn crc32_mpeg(data: &[u8]) -> u32 { + let mut crc = u32::MAX; + for &b in data { + crc ^= u32::from(b) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { crc << 1 ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc +} diff --git a/crates/media/tests/it/downmix.rs b/crates/media/tests/it/downmix.rs index e0e7d2252..05205ab6a 100644 --- a/crates/media/tests/it/downmix.rs +++ b/crates/media/tests/it/downmix.rs @@ -94,8 +94,18 @@ fn cases() -> Vec { let (n71, g71) = seven_one(); let k5 = 1.0 / (1.0 + 2.0 * K); let kq = 1.0 / (1.0 + K); + let k61 = 1.0 / (1.5 + 2.0 * K); vec![ Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { + file: "dm61.m4a", + layout: "6.1", + names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"], + codec: &["-c:a", "aac", "-b:a", "448k"], + gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]], + tol: 0.15, + }, + Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 }, Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, @@ -154,24 +164,58 @@ fn each_channel_reaches_stereo_at_its_bs775_gain() { for c in cases() { let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue }; - let s = ours(&p); - let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) - for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { - for (side, gain) in g.iter().enumerate() { - let got = amp(&s, side, freqs[i]); - let want = sine * gain; - assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); - } - } - let theirs = ffmpeg_ac2(&p); - let (a, b) = (rms(&s), rms(&theirs)); - let db = 20.0 * (a / b).log10(); - assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); - let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); - assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); + check(&c, &p); } } +/// `p`, or a copy whose AAC sample entry names all `channels` of its PCE layout: ffmpeg 4.2 writes +/// 2 there for a PCE layout (8.1 the real count), and FilmCraft decodes as many channels as the +/// sample entry names, so the other five of a 6.1 would never reach the mix. +fn whole_pce_entry(p: &Path, channels: usize) -> PathBuf { + let mut bytes = std::fs::read(p).expect("read"); + let Some((asc, 2)) = aac_entry(&bytes) else { return p.to_path_buf() }; + if channels <= 2 || filmcraft_aac::AudioSpecificConfig::parse(&asc).map(|a| a.channel_config).ok() != Some(0) { + return p.to_path_buf(); + } + // the `mp4a` entry's channelcount follows its 6 reserved bytes, data reference index and + // 8 reserved bytes + let at = bytes.windows(4).position(|w| w == b"mp4a").expect("an mp4a sample entry") + 4 + 6 + 2 + 8; + bytes[at..at + 2].copy_from_slice(&(channels as u16).to_be_bytes()); + eprintln!("{}: its sample entry names 2 of {channels} channels (ffmpeg 4.2): reading a copy naming all", p.display()); + let out = p.with_extension("entry.m4a"); + std::fs::write(&out, bytes).expect("write"); + out +} + +/// The AudioSpecificConfig and sample entry channel count of an MP4's first AAC track. +fn aac_entry(bytes: &[u8]) -> Option<(Vec, u32)> { + let file = filmcraft_isobmff::open(bytes).ok()?; + file.tracks.iter().flat_map(|t| &t.entries).find_map(|e| match &e.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some((a.asc.clone(), e.audio.as_ref()?.channels)), + _ => None, + }) +} + +fn check(c: &Case, p: &Path) { + let p = &whole_pce_entry(p, c.names.len()); + let s = ours(p); + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) + for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { + for (side, gain) in g.iter().enumerate() { + let got = amp(&s, side, freqs[i]); + let want = sine * gain; + assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); + } + } + let theirs = ffmpeg_ac2(p); + let (a, b) = (rms(&s), rms(&theirs)); + let db = 20.0 * (a / b).log10(); + assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); + let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); + assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); +} + /// Full-scale in every channel must not clip. #[test] fn loud_surround_does_not_clip() { diff --git a/crates/media/tests/it/footage.rs b/crates/media/tests/it/footage.rs index 4d539c4e5..0cae33417 100644 --- a/crates/media/tests/it/footage.rs +++ b/crates/media/tests/it/footage.rs @@ -206,9 +206,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -513,12 +518,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case. #[test] -fn mxf_is_importable() { - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); - assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -564,6 +569,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame_in(&p, n, "bt601")); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; @@ -915,7 +1038,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel). - let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); + let gains = effectcraft_media::downmix::stereo_gains(channels as usize); let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); let rms = (sum / n.max(1) as f64).sqrt(); diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs index 5f7fb8037..69bed4a9f 100644 --- a/crates/media/tests/it/main.rs +++ b/crates/media/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). +mod aac_layout; mod downmix; mod exif_orientation; mod footage; diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 83e2102dc..61fd0e6c3 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { p } +/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right, +/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii +/// are 0; radii that would overlap are scaled down as CSS `border-radius` does. +pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath { + // Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners + // clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must + // not make a trim jump (#1896). + let w = if size[0].is_finite() { size[0].abs() } else { 0.0 }; + let h = if size[1].is_finite() { size[1].abs() } else { 0.0 }; + let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0); + let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0); + let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest + // edge ratio (a uniform radius then clamps to half the shorter side, as before). + let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 }; + let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h)); + if f < 1.0 { + for v in &mut r { + *v *= f; + } + } + let [tl, tr, br, bl] = r; + // Quarter-circle Bezier control distance. + const K: f64 = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to((x1, y0 + tr)); + p.line_to((x1, y1 - br)); + if br > 0.0 { + p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1)); + } + p.line_to((x0 + bl, y1)); + if bl > 0.0 { + p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl)); + } + p.line_to((x0, y0 + tl)); + if tl > 0.0 { + p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0)); + } + p.line_to((x1 - tr, y0)); + if tr > 0.0 { + p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr)); + } + p.close_path(); + p +} + pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { to_kurbo(&ShapePath::ellipse(pos, size[0], size[1])) } @@ -419,6 +465,11 @@ mod tests { let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); let (a, b) = ends(&round[0]); assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Per-corner roundness starts and runs in the same order: ticking the checkbox must not + // move a Trim Paths contour. + let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0); + let (a, b) = ends(&corners[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {a:?} {b:?}"); // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); let (a, b) = ends(&swapped[0]); @@ -456,6 +507,32 @@ mod tests { assert!((area - 200.0).abs() < 1.0, "{area}"); } + #[test] + fn rect_radii_uniform_matches_a_rounded_rect() { + for r in [0.0, 5.0, 25.0, 100.0] { + let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]); + let clamped = r.min(25.0); + let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped; + assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area()); + assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6); + } + } + + #[test] + fn rect_radii_mixes_corners_and_scales_overlaps() { + // Only the top-left and bottom-right corners round. + let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]); + let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0); + assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area()); + // Radii wider than the top edge scale down together, like CSS border-radius. + let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]); + let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]); + assert!((p.area() - scaled.area()).abs() < 1e-9); + // Negative and non-finite radii are square corners. + let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]); + assert!((p.area().abs() - 2500.0).abs() < 1e-9); + } + #[test] fn stroke_has_area() { let r = rect([40.0, 40.0], [50.0, 50.0], 0.0); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 95ed262d4..1aa8d7c90 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec) -> PropGrou } pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup { + // Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape. + let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 }; indexed(ids.group("rect", "Rectangle Path 1")) .with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden)) .with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels)) .with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point)) .with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels)) + .with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) } pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup { @@ -596,6 +603,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(wiggle_spatial_phase(ids)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), + "wiggler" | "wiggleTransform" => { + // The Transform group holds how far each transformation wiggles. + let tr = ids + .group("transform", "Transform") + .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) + .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + indexed(ids.group("wiggler", "Wiggle Transform 1")) + .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))) + .with(tr) + } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add", @@ -681,10 +704,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { } if kind == LightKind::Environment { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; - // none = the comp's Environment Layer. + // none = the comp's Environment Layer, or a flat default environment of the light's + // colour × intensity when there is none. No Falloff: an environment light shines from + // every direction at once. Environment lights cast shadows too (After Effects' Light + // Settings shows Cast Shadows for them; not for Ambient) (#2436). return g .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer)) - .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)); } if kind != LightKind::Ambient { g = g diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 36069e637..aa4ff7201 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -87,8 +87,8 @@ pub struct Comp { pub display_start: Tick, pub background: [f32; 3], pub work_area: (Tick, Tick), - /// Layer #1 first (top of the stack). - pub layers: Vec, + /// Layer #1 first (top of the stack). Each layer is shared between undo states until edited. + pub layers: super::Layers, #[serde(default)] pub markers: Vec, pub shutter_angle: f64, @@ -188,7 +188,7 @@ impl Comp { display_start: Tick::ZERO, background: [0.0, 0.0, 0.0], work_area: (Tick::ZERO, duration), - layers: vec![], + layers: super::Layers::new(), markers: vec![], shutter_angle: 180.0, shutter_phase: -90.0, @@ -239,7 +239,7 @@ impl Comp { } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.layers.iter_mut().find(|l| l.id == id) + self.layers.find_mut(|l| l.id == id) } /// 1-based layer number (#) of a layer. diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 486bc25f7..8cf07ea2e 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -44,6 +44,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] @@ -52,6 +57,142 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the + /// converted black and white levels (0–1; above 1 for over-range highlights). + pub fn convert(&self, v: f32) -> f32 { + let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff); + let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; + if out.is_finite() { out } else { 0.0 } + } +} + +/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter +/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff +/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes +/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default +/// Gamma 1.7 gives linear light). +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(scale / gamma.max(0.01)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -98,6 +239,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -149,6 +293,7 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, @@ -160,6 +305,17 @@ impl Default for Footage { } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/layers.rs b/crates/project/src/layers.rs new file mode 100644 index 000000000..6119a5753 --- /dev/null +++ b/crates/project/src/layers.rs @@ -0,0 +1,209 @@ +//! A comp's layer stack with structural sharing. +//! +//! Every undoable edit keeps the previous project for undo, so the comp it touches is shared and +//! `Arc::make_mut` copies it. With the layers stored inline that copy is the whole stack, every +//! layer and every keyframe, so an edit costs time in proportion to the comp's size and a script +//! that sets many keys in a big comp goes quadratic. Here each layer sits behind its own `Arc`: +//! copying the stack copies pointers, and only the layer an edit actually changes is copied. +//! +//! The API mirrors the `Vec` it replaces. Serialized form is identical (a list of layers). + +use std::ops::{Index, IndexMut}; +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::layer::Layer; + +/// Layer #1 first (top of the stack). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layers(Vec>); + +impl Layers { + pub const fn new() -> Self { + Self(Vec::new()) + } + pub fn with_capacity(n: usize) -> Self { + Self(Vec::with_capacity(n)) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn iter(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.0.iter().map(|l| &**l) + } + /// Mutable access to every layer: each one that is still shared with an undo state is copied, + /// so prefer [`Layers::get_mut`] (or `iter` to find, then `get_mut`) when only some change. + pub fn iter_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter_mut().map(Arc::make_mut) + } + /// Mutable access to just the layers `keep` accepts. `keep` reads each layer without copying + /// it; only the matching ones are copied (if shared). Use this, not `iter_mut().filter(..)`. + pub fn iter_mut_where<'a>(&'a mut self, mut keep: impl FnMut(&Layer) -> bool + 'a) -> impl Iterator + 'a { + self.0.iter_mut().filter(move |l| keep(l)).map(Arc::make_mut) + } + /// The first layer `pred` accepts, copied only if it is that one. Use this, not `iter_mut().find(..)`. + pub fn find_mut(&mut self, mut pred: impl FnMut(&Layer) -> bool) -> Option<&mut Layer> { + self.0.iter_mut().find(|l| pred(l)).map(Arc::make_mut) + } + pub fn get(&self, i: usize) -> Option<&Layer> { + self.0.get(i).map(|l| &**l) + } + pub fn get_mut(&mut self, i: usize) -> Option<&mut Layer> { + self.0.get_mut(i).map(Arc::make_mut) + } + pub fn first(&self) -> Option<&Layer> { + self.get(0) + } + pub fn last(&self) -> Option<&Layer> { + self.0.last().map(|l| &**l) + } + pub fn first_mut(&mut self) -> Option<&mut Layer> { + self.get_mut(0) + } + pub fn last_mut(&mut self) -> Option<&mut Layer> { + self.0.last_mut().map(Arc::make_mut) + } + pub fn push(&mut self, l: Layer) { + self.0.push(Arc::new(l)); + } + pub fn insert(&mut self, i: usize, l: Layer) { + self.0.insert(i, Arc::new(l)); + } + pub fn remove(&mut self, i: usize) -> Layer { + Arc::unwrap_or_clone(self.0.remove(i)) + } + pub fn pop(&mut self) -> Option { + self.0.pop().map(Arc::unwrap_or_clone) + } + pub fn retain(&mut self, mut f: impl FnMut(&Layer) -> bool) { + self.0.retain(|l| f(l)); + } + pub fn swap(&mut self, a: usize, b: usize) { + self.0.swap(a, b); + } + pub fn truncate(&mut self, n: usize) { + self.0.truncate(n); + } + pub fn clear(&mut self) { + self.0.clear(); + } + pub fn reverse(&mut self) { + self.0.reverse(); + } + pub fn sort_by_key(&mut self, mut f: impl FnMut(&Layer) -> K) { + self.0.sort_by_key(|l| f(l)); + } + pub fn sort_by(&mut self, mut f: impl FnMut(&Layer, &Layer) -> std::cmp::Ordering) { + self.0.sort_by(|a, b| f(a, b)); + } + pub fn rotate_left(&mut self, n: usize) { + self.0.rotate_left(n); + } + pub fn rotate_right(&mut self, n: usize) { + self.0.rotate_right(n); + } + /// `layers[range].rotate_left(n)` (a slice of `Arc`s can't be handed out as a `&mut [Layer]`). + pub fn rotate_range_left(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_left(n); + } + } + pub fn rotate_range_right(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_right(n); + } + } + pub fn split_off(&mut self, at: usize) -> Layers { + Layers(self.0.split_off(at)) + } + pub fn append(&mut self, other: &mut Layers) { + self.0.append(&mut other.0); + } + /// Remove a range of layers and return them (owned). + pub fn drain(&mut self, r: impl std::ops::RangeBounds) -> std::vec::IntoIter { + self.0.drain(r).map(Arc::unwrap_or_clone).collect::>().into_iter() + } + /// Whether `self` and `other` hold the very same (unchanged) layer at `i`: a cheap "did this + /// layer change" test between two project states. + pub fn same_at(&self, other: &Layers, i: usize) -> bool { + matches!((self.0.get(i), other.0.get(i)), (Some(a), Some(b)) if Arc::ptr_eq(a, b)) + } + /// The shared layers themselves, for parallel iteration (`rayon` over `&[Arc]`). + pub fn as_arcs(&self) -> &[Arc] { + &self.0 + } + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } +} + +impl Index for Layers { + type Output = Layer; + fn index(&self, i: usize) -> &Layer { + &self.0[i] + } +} + +impl IndexMut for Layers { + fn index_mut(&mut self, i: usize) -> &mut Layer { + Arc::make_mut(&mut self.0[i]) + } +} + +impl From> for Layers { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(Arc::new).collect()) + } +} + +impl FromIterator for Layers { + fn from_iter>(it: I) -> Self { + Self(it.into_iter().map(Arc::new).collect()) + } +} + +impl Extend for Layers { + fn extend>(&mut self, it: I) { + self.0.extend(it.into_iter().map(Arc::new)); + } +} + +impl IntoIterator for Layers { + type Item = Layer; + type IntoIter = std::iter::Map>, fn(Arc) -> Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter().map(Arc::unwrap_or_clone as fn(Arc) -> Layer) + } +} + +impl<'a> IntoIterator for &'a Layers { + type Item = &'a Layer; + type IntoIter = std::iter::Map>, fn(&'a Arc) -> &'a Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter().map(|l| &**l) + } +} + +impl<'a> IntoIterator for &'a mut Layers { + type Item = &'a mut Layer; + type IntoIter = std::iter::Map>, fn(&'a mut Arc) -> &'a mut Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut().map(Arc::make_mut) + } +} + +impl Serialize for Layers { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Layers { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Layers::from) + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..6cfbda344 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,8 +1,8 @@ //! The EffectCraft document model. //! //! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree -//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not -//! change. +//! ([`props`]). Compositions, and each layer within them ([`Layers`]), sit behind `Arc` so undo +//! snapshots share everything that did not change. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -33,12 +33,14 @@ mod comp; mod footage; mod item; mod layer; +mod layers; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; +pub use layers::Layers; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs index ffedaf7c2..fd59fedda 100644 --- a/crates/project/src/styles.rs +++ b/crates/project/src/styles.rs @@ -495,7 +495,7 @@ mod tests { let mut ids = Ids(&mut p.next_id); add_style(&mut a, &mut ids, "dropShadow", &gl, true); add_style(&mut b, &mut ids, "bevelEmboss", &gl, true); - comp.layers = vec![a, b]; + comp.layers = vec![a, b].into(); comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0); assert!(needs_sync(&comp)); sync_comp(&mut comp); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c0bdb012f..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(!p.comp_contains(c[n - 1], c[0])); } +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} + /// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. #[test] fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 4f93e41d7..e6bbd2113 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -131,6 +131,14 @@ pub trait FxTarget { buf }); } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index ee19d8b25..e2861340c 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -406,7 +406,7 @@ mod tests { pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]); } let mut comp = comp; - comp.layers = vec![l1, l2]; + comp.layers = vec![l1, l2].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); assert!(comp_has_audio(&p, cid)); let rate = 1000; @@ -452,11 +452,11 @@ mod tests { let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); let mut inner = base.clone(); - inner.layers = vec![tone]; + inner.layers = vec![tone].into(); let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into())); let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None); let mut outer = base.clone(); - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into())); let rate = 8000; let t = Tick::from_seconds_f64(0.5); @@ -483,7 +483,7 @@ mod tests { let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); tone.stretch = stretch; let mut comp = base.clone(); - comp.layers = vec![tone]; + comp.layers = vec![tone].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let n = 2000usize; let t = Tick::from_seconds_f64(0.5); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 1c7aadc55..b6f2e4076 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { - return group_has(c, "wiggle"); + return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 8d6761516..4ea36d9b8 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -1278,7 +1278,7 @@ mod tests { let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)].into(); let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 04dd706db..db1561ad6 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,4 @@ -use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick}; +use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick}; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other @@ -93,6 +93,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 3024dfc52..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,7 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0c2f11e4c..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,8 +48,8 @@ mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; pub use frames::{ - adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, - roi_inside, + adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, + render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index d51344923..a4927144f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1,6 +1,6 @@ use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; -use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; +use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::{ @@ -566,14 +566,26 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|s| s.skips_cover()) { + target.align(layer_size); + } else { + target.cover(layer_size); + } } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { @@ -817,16 +829,17 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -844,6 +857,10 @@ impl<'a> Renderer<'a> { if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -851,8 +868,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { @@ -862,7 +880,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 67f224793..2497ea807 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag, -//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including -//! the paths of nested groups; +//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the +//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top; @@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; +use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor +/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its +/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly +/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above +/// the operation move on their own as far as Correlation allows, and the copies of a Repeater +/// below it move as one. The four transformations of one operation move together. +fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap, draws: &mut [Draw]) { + let Some(tr) = g.sub("transform") else { return }; + let w = Wiggly { + max: 1.0, + min: -1.0, + wiggles_per_second: ctx.f(layer, g, "speed", 2.0), + correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, g, "phase", 0.0), + spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0), + lock_dimensions: false, + seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64, + }; + let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]); + let pos = ctx.v2(layer, tr, "position", [0.0; 2]); + let scale = ctx.v2(layer, tr, "scale", [0.0; 2]); + let rot = ctx.f(layer, tr, "rotation", 0.0); + let t = layer.layer_time(ctx.time).seconds(); + let matrix = |k: usize| { + let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1)); + Mat3::translate(vec2(pos[0] * x, pos[1] * y)) + * Mat3::rotate_deg(rot * x) + * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0)) + * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y)) + }; + let mut by_copy: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + for &s in live { + if !done.insert(s) { + continue; + } + let k = copy_of.get(&s).copied().unwrap_or(0); + let m = *by_copy.entry(k).or_insert_with(|| matrix(k)); + arena[s] = mat_path(&arena[s], &m); + } + // A paint above the operation whose paths all moved by one matrix moves its gradient and + // stroke scale with them, as a group transform does. A paint over copies that moved by + // different matrices stays one paint (its fill rule and opacity apply to all of them at once) + // and keeps its gradient and stroke scale. + for d in draws { + let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten()); + let Some(Some(m)) = ms.next() else { continue }; + if ms.all(|x| x == Some(m)) { + d.xf = m * d.xf; + } + } +} + /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec) -> (Vec, Vec) { + collect_in(ctx, layer, contents, arena, &mut Copies::default()) +} + +/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater +/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's +/// groups, so copies made in a nested group stay apart in the groups around it. +#[derive(Default)] +struct Copies { + of: HashMap, + next: usize, +} + +fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, copy_ids: &mut Copies) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; @@ -201,7 +269,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let size = ctx.v2(layer, g, "size", [100.0; 2]); let pos = ctx.v2(layer, g, "position", [0.0; 2]); - let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)); + let p = if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [ + ctx.f(layer, g, "topLeftRoundness", 0.0), + ctx.f(layer, g, "topRightRoundness", 0.0), + ctx.f(layer, g, "bottomRightRoundness", 0.0), + ctx.f(layer, g, "bottomLeftRoundness", 0.0), + ], + ) + } else { + effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)) + }; push_shape(ctx, layer, g, p, arena, &mut live); } "ellipse" => { @@ -330,6 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec wiggle_transform(ctx, layer, g, arena, &live, ©_ids.of, &mut draws), "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); @@ -342,7 +424,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; - let (cd, cl) = collect(ctx, layer, inner, arena); + let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m); @@ -379,12 +461,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec> = Vec::with_capacity(n); + // Each copy of each earlier copy (a second Repeater) gets an id of its own. + let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1); + let base = copy_ids.of.get(&s).copied().unwrap_or(0); + let id = *ids.entry((i, base)).or_insert_with(|| { + copy_ids.next += 1; + copy_ids.next + }); + copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map); diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index e6f6cba7c..ba7028a25 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -310,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`. +fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] { + let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height as i64 { + let a = alpha(img, x, y); + sx += a * x as f32; + sy += a * y as f32; + sa += a; + } + } + [sx / sa, sy / sa] +} + +fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup { + op(ids, "wiggler", params) +} + +#[test] +fn wiggle_transform_moves_paths_over_time() { + let run = |position: [f64; 2], t: f64| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]); + vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0) + }; + // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5. + let rest = [99.5, 49.5]; + assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest); + let a = run([15.0, 15.0], 0.37); + assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame"); + let b = run([15.0, 15.0], 1.21); + let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200)); + assert!(ca != rest && ca != cb, "{ca:?} {cb:?}"); + for c in [ca, cb] { + assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}"); + } + // A translation keeps the square's area. + assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a)); + // The layer cache keys a layer with a Wiggle Transform by time. + let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]); + let comp = p.comp(cid).unwrap(); + let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false); + assert_ne!(key(0.37), key(1.21)); +} + +#[test] +fn wiggle_transform_scales_and_rotates() { + let run = |params: &[(&str, Value)]| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, params); + vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0) + }; + let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))])); + assert!((scaled - 1600.0).abs() > 20.0, "{scaled}"); + let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]); + assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated)); + assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away"); +} + +/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform. +#[test] +fn wiggle_transform_moves_the_gradient_with_the_path() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), + build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g), + wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let cols: Vec = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect(); + let (l, r) = (cols[0], cols[cols.len() - 1]); + assert!((l - 80).abs() > 3, "the square moved: {l}"); + let (cl, cr) = (img.get(l, 50), img.get(r, 50)); + assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}"); +} + +/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike +/// (Correlation 100). +#[test] +fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let rows: Vec = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect(); + let (t, b) = (rows[0], rows[rows.len() - 1]); + assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}"); + let (ct, cb) = (img.get(cx, t), img.get(cx, b)); + assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}"); + } +} + +/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's +/// middle at the group's origin row. +#[test] +fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let c = img.get(cx, 50); + assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}"); + } +} + +/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill +/// covers them once. +#[test] +fn wiggle_transform_keeps_one_fill_over_overlapping_copies() { + let (p, cid) = shape_comp(|ids| { + let mut fill = build::shape_fill(ids, RED); + set(&mut fill, "opacity", Value::Scalar(50.0)); + vec![ + build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [20.0, 0.0]), + fill, + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max); + assert!((top - 0.5).abs() < 0.01, "{top}"); +} + +/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies. +#[test] +fn long_repeater_stack_with_cleared_copies_renders() { + let (p, cid) = shape_comp(|ids| { + let mut items = vec![]; + for _ in 0..70 { + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0])); + items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0])); + } + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0])); + items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])); + items.push(build::shape_fill(ids, RED)); + items + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img)); +} + +/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one +/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same. +#[test] +fn wiggle_transform_and_repeater_order() { + let offsets = |wiggle_first: bool, correlation: f64| { + let (p, cid) = shape_comp(|ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0); + let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]); + let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]); + let fill = build::shape_fill(ids, RED); + if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] } + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0) + }; + let own = offsets(false, 0.0); + assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}"); + // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own. + let (p, cid) = shape_comp(|ids| { + let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)]; + let group = build::shape_group(ids, "Repeated", inner); + vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0); + assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}"); + for same in [offsets(false, 100.0), offsets(true, 0.0)] { + assert!(same[0].abs() > 0.5, "{same:?}"); + assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}"); + } +} + #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| { @@ -519,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() { let b = shape_buf(&p, cid); assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height); } + +#[test] +fn rect_per_corner_roundness_rounds_the_named_corners() { + let render = |per_corner: bool, radii: [f64; 4], roundness: f64| { + let (p, cid) = shape_comp_sized(200, 100, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + vec![g, build::shape_fill(ids, RED)] + }); + frame(&p, cid) + }; + // Only the top-left corner rounds: its corner pixels fade, the other three stay filled. + let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0); + assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2)); + for (x, y) in [(148, 2), (148, 98), (52, 98)] { + assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y)); + } + // The checkbox off ignores the four values (the single Roundness still rules). + let off = render(false, [30.0; 4], 0.0); + assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off)); + // Four equal per-corner radii render like the same Roundness. + let per = render(true, [25.0; 4], 0.0); + let uni = render(false, [0.0; 4], 25.0); + assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni)); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 38baa8f3e..488f38ec7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -314,7 +314,7 @@ fn particle_world_scene(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, l]; + p.comp_mut(cid).unwrap().layers = vec![cam, l].into(); (p, cid) } @@ -373,7 +373,7 @@ fn burst_scene_keys(cut: Option, start: f64, stretch: f64, hold: bool) -> ( l.in_point = l.start_time; l.out_point = Tick::from_seconds_f64(4.0); l.stretch = stretch; - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); (p, cid) } @@ -507,7 +507,7 @@ fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)]; } - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); (p, cid) } @@ -614,7 +614,7 @@ fn cache_scene() -> (Project, ItemId) { vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![text, shape, bg]; + c.layers = vec![text, shape, bg].into(); (p, cid) } @@ -854,7 +854,7 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each. let mut q = [0.0f32; 4]; @@ -888,7 +888,7 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each. let mut q = [0.0f32; 4]; @@ -917,7 +917,7 @@ fn effects_on_text_reach_past_the_text_bounds() { let plain = render_frame(&p, cid, Tick::ZERO, 1.0); let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); let (mut lo, mut hi) = (u32::MAX, 0u32); for y in 0..100 { @@ -951,7 +951,7 @@ fn cc_smear_trails_text_past_its_bounds() { "ec.distort.ccsmear", &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } @@ -974,7 +974,7 @@ fn layer_parameters_see_a_whole_shape_layer() { let matte_id = shape.id; let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]); - p.comp_mut(cid).unwrap().layers = vec![s, shape]; + p.comp_mut(cid).unwrap().layers = vec![s, shape].into(); let f = render_frame(&p, cid, Tick::ZERO, 1.0); // The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside. for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] { @@ -1064,7 +1064,7 @@ fn self_inputs_share_source_semantics_across_effects_and_times() { "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let c = p.comp(cid).unwrap(); let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some()); @@ -1082,7 +1082,7 @@ fn timed_source_reads_preserve_other_layer_masks() { let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); p.next_id = next; other.props.sub_mut("masks").unwrap().children.push(mask.into()); - p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()]; + p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into(); let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None }; @@ -1103,7 +1103,7 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() { let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated]; + p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into(); let key = |p: &Project, time: f64, limit| { let comp = p.comp(cid).unwrap(); let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time)); @@ -1144,12 +1144,12 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))]; let mut nested = comp.clone(); - nested.layers = vec![child]; + nested.layers = vec![child].into(); let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into())); let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None); let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, pre]; + p.comp_mut(cid).unwrap().layers = vec![owner, pre].into(); let key = |p: &Project, time| { let c = p.comp(cid).unwrap(); crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap() @@ -1224,7 +1224,7 @@ fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, owner, source]; + p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into(); (p, cid) } @@ -1336,7 +1336,7 @@ fn instance_scene(pan: f64) -> (Project, ItemId) { } let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); - p.comp_mut(cid).unwrap().layers = vec![cam, owner, model]; + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model].into(); (p, cid) } diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index 242a5a5e2..a6c85f2de 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -78,7 +78,7 @@ fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, l.props.sub_mut("effects").unwrap().children.push(g.into()); } let mut comp = comp; - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -272,7 +272,7 @@ fn audio_spectrum_follows_time_through_the_layer_cache() { g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); s.props.sub_mut("effects").unwrap().children.push(g.into()); let mut comp = comp; - comp.layers = vec![s, a]; + comp.layers = vec![s, a].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = Signal(late_tone); diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index eb340a4ca..30d2163c1 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -53,17 +53,17 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { a.parent = Some(null.id); b.parent = Some(null.id); let mut c = outer.clone(); - c.layers = vec![null, b, a, bg]; + c.layers = vec![null, b, a, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) } else { let mut inner = inner; - inner.layers = vec![b, a]; + inner.layers = vec![b, a].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); xf(&mut pre); pre.switches.collapse = collapse; let mut c = outer.clone(); - c.layers = vec![pre, bg]; + c.layers = vec![pre, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) }; (p, cid) @@ -154,7 +154,7 @@ fn referenced_layer_overrides_change_the_owner_cache_key() { p.next_id = next; b.props.sub_mut("effects").unwrap().children.push(fx.into()); let mut c = comp; - c.layers = vec![b, a]; + c.layers = vec![b, a].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let comp = p.comp(cid).unwrap(); let b = comp.layer(bid).unwrap(); @@ -182,7 +182,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None); pre.switches.three_d = true; @@ -190,7 +190,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]); red.switches.three_d = true; let mut c = outer; - c.layers = vec![red, pre]; + c.layers = vec![red, pre].into(); let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) }; @@ -228,11 +228,11 @@ fn scaled_square(cr: bool, via_parent: bool) -> Image { set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); l.parent = Some(null.id); - c.layers = vec![l, null]; + c.layers = vec![l, null].into(); } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - c.layers = vec![l]; + c.layers = vec![l].into(); } let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) @@ -261,7 +261,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]); l.switches.quality = Quality::Wireframe; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert_eq!(img.get(50, 50)[3], 0.0, "no fill"); @@ -272,7 +272,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]); l.switches.quality = Quality::Draft; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); @@ -290,13 +290,13 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); pre.switches.three_d = three_d_precomp; pre.switches.collapse = true; let mut c = outer.clone(); - c.layers = vec![pre]; + c.layers = vec![pre].into(); if camera { let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); c.layers.insert(0, cam); @@ -327,7 +327,7 @@ fn text_scaled_up_stays_sharp_without_the_switch() { set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); assert!(!l.switches.collapse); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; @@ -351,7 +351,7 @@ fn unscaled_text_rasterises_at_the_output_scale() { let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() })); - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); for i in 0..500 { let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0]; @@ -387,7 +387,7 @@ fn effects_on_a_scaled_shape_follow_its_scale() { set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; diff --git a/crates/render/src/tests/guides.rs b/crates/render/src/tests/guides.rs index cb9851250..44f3fc7b0 100644 --- a/crates/render/src/tests/guides.rs +++ b/crates/render/src/tests/guides.rs @@ -40,7 +40,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { guide.switches.guide = true; let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); let mut inner = inner; - inner.layers = vec![guide, bg]; + inner.layers = vec![guide, bg].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); if matches!(nest, Nest::CollapsedAdvanced) { @@ -49,7 +49,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); pre.switches.collapse = !matches!(nest, Nest::Precomp); pre.switches.three_d = three_d; - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); (p, iid, oid) } diff --git a/crates/render/src/tests/motion_tile.rs b/crates/render/src/tests/motion_tile.rs index 7d1645a02..28faca73c 100644 --- a/crates/render/src/tests/motion_tile.rs +++ b/crates/render/src/tests/motion_tile.rs @@ -58,7 +58,7 @@ fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { tile_then_scale(&mut p, &mut adj, out, scale); let mut layers = vec![adj]; layers.extend(picture(&mut p, &comp)); - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } @@ -68,7 +68,7 @@ fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); let layers = picture(&mut p, &inner); - p.comp_mut(iid).unwrap().layers = layers; + p.comp_mut(iid).unwrap().layers = layers.into(); let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); tile_then_scale(&mut p, &mut pre, out, scale); p.comp_mut(cid).unwrap().layers.push(pre); @@ -151,7 +151,7 @@ fn adjustment_motion_tile_matches_the_after_effects_frames() { } adj.props.sub_mut("effects").unwrap().children.push(g.into()); } - p.comp_mut(cid).unwrap().layers = vec![adj, red]; + p.comp_mut(cid).unwrap().layers = vec![adj, red].into(); // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). let img = render_frame(&p, cid, Tick::ZERO, 0.25); let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); diff --git a/crates/render/src/tests/roi.rs b/crates/render/src/tests/roi.rs index 9182817cf..a9892d9c7 100644 --- a/crates/render/src/tests/roi.rs +++ b/crates/render/src/tests/roi.rs @@ -29,7 +29,7 @@ fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { } layers.push(l); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index b59f009de..2450fcf64 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -44,7 +44,7 @@ fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Co if let Some(bg) = bg { layers.push(solid(&mut p, &comp, bg, 200, 100)); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 395057935..1c37a6267 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -13,7 +13,7 @@ use std::collections::BinaryHeap; use rayon::prelude::*; -use super::scene::{Scene, ShadowMap, Vertex}; +use super::scene::{EnvInfo, Scene, ShadowMap, Vertex}; use super::shade::{Frag, sample, shade}; /// A rendered target at raster size. @@ -500,11 +500,39 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } -/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). +/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere +/// from its dominant direction (the light travels from the bright region into the scene), like +/// a parallel light's. +fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap { + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); + let sun = e.sun.map(|v| v as f64); + let dir = [-sun[0], -sun[1], -sun[2]]; + let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; + let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; + let focal = (size as f64 * 0.5 / radius) as f32; + ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 } +} + +/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of +/// the Environment light when it casts shadows and its image has a dominant direction (marked +/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) .filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows)) .collect(); + // The Environment light casts a shadow when marked and its image has a dominant direction; + // with nothing to cast one, its mark is cleared either way. + let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]); + if !env_shadow && let Some(e) = &mut s.env { + e.shadow = -1; + } if casters.is_empty() { for l in &mut s.lights { l.shadow = -1; @@ -536,6 +564,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { 1 } }) + // The Environment light's map is a single orthographic one. + .chain(std::iter::once(if env_shadow { 1 } else { 0 })) .collect(); let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; @@ -583,6 +613,25 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } s.lights[li].shadow = first; } + // The Environment light's shadow: one orthographic map over the scene from its dominant + // direction (the light travels from the bright region into the scene), after the lights'. + if env_shadow && let Some(e) = s.env { + match sizes.get(s.lights.len()).copied().flatten() { + Some(size) => { + let first = maps.len() as i32; + maps.push(env_shadow_map(e, centre, radius, size)); + if let Some(e) = &mut s.env { + e.shadow = first; + } + } + // The texel budget dropped it. + None => { + if let Some(e) = &mut s.env { + e.shadow = -1; + } + } + } + } let texels: Vec> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect(); for (m, t) in maps.iter_mut().zip(texels) { m.offset = s.shadow_texels.len() as u32; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 64cb4b579..d66d8db71 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -172,6 +172,24 @@ pub struct EnvInfo { pub intensity: f32, /// Radians about the vertical axis. pub rotation: f32, + /// Unit direction from the scene towards the environment's dominant (radiance-weighted) + /// bright region — the direction its shadow is cast from, like a parallel light's + /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright + /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow. + pub sun: [f32; 3], + /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the + /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment + /// light's shadow occludes only this much of its light: the key lobe is blocked where + /// the map says so, the remaining fill keeps shining, so the shadow stays partial the + /// way After Effects renders an area-light shadow (a broad environment like the default + /// studio shadows softly, a lone bright spot on black almost fully). + pub key: f32, + /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), + /// -1 casts no shadow. Gated by the light's Cast Shadows. + pub shadow: i32, + pub shadow_darkness: f32, + /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to). + pub shadow_diffusion: f32, } #[derive(Clone, Debug, Default)] @@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } } -/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour, -/// rotation)). -fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option, [f32; 3], f32)>) { +/// The evaluated Environment light: its image source, intensity × colour, rotation, and +/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). +#[derive(Clone, Copy)] +pub(crate) struct EnvLight { + src: Option, + color: [f32; 3], + rot: f32, + casts_shadows: bool, + shadow_darkness: f64, + shadow_diffusion: f64, +} + +/// Evaluated lights: (scene lights, ambient sum, environment light). +fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; @@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option { @@ -783,8 +819,54 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } +/// The environment image's dominant direction and key strength: the mean texel direction +/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied, +/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or +/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero, +/// and opposing bright regions that cancel leave no mean either — though a chromatic one need +/// not stay directionless once tinted: a red light over equally bright red and blue opposite +/// regions removes the blue pull and points at the red one). The mean of a single bright spot +/// on black points straight at it; a gradient points somewhere between its ends. The returned +/// key fraction is the mean vector's length over the radiance weight — how directional the +/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment +/// light's shadow can remove. +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) { + use super::shade::equirect_dir; + let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); + let mut weight = 0.0f32; + for (i, p) in d.iter().enumerate() { + let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); + let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); + // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the + // environment emits, not the raw texels. + let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin(); + let dir = equirect_dir(u, v, rot); + x += dir[0] * lum; + y += dir[1] * lum; + z += dir[2] * lum; + weight += lum.abs(); + } + // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow + // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. + let len = (x * x + y * y + z * z).sqrt(); + if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { + return ([0.0; 3], 0.0); + } + ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) +} + /// Prefilter an environment image into the scene's textures. -pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) { +pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) { + // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. + // A white (or off) light leaves them as they are. + let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0; + let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; + // The shadow direction and key strength from the full-resolution image, before the mip + // chain averages it — only a shadow-casting light reads them, so only one pays for the + // pass — and weighted by the tint, so the shadow follows the radiance the light emits: + // opposing chromatic regions that cancel untinted need not cancel once the colour removes + // one of them (a red light over equally bright red and blue sides shadows from the red). + let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -807,10 +889,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; - // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { - let tint = intensity.map(|c| c / k); + if tint != [1.0, 1.0, 1.0] { for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { @@ -820,16 +899,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 } } } - b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation }); + b.s.env = Some(EnvInfo { + radiance: first, + mips: levels, + irradiance: ii, + intensity: k, + rotation: env.rot, + sun, + key, + // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). + shadow: if env.casts_shadows { 0 } else { -1 }, + shadow_darkness: env.shadow_darkness as f32, + shadow_diffusion: env.shadow_diffusion as f32, + }); } -/// The environment layer's pixels as linear straight RGBA. -fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> { +/// The Environment light's image: `Absent` when the light has no Source and the comp has no +/// Environment Layer (the default studio environment applies — After Effects "uses a default +/// environment map" for an Environment light too), `Image` when the source renders, `Failed` +/// when a selected or found source produced no pixels (a deleted layer, missing footage, a +/// failed decode). A failed source must not silently become the default environment — that +/// would relight and reshadow the scene behind the user's back — so, like any layer whose +/// source fails renders nothing, it contributes nothing (#2436). +enum EnvPixels { + Absent, + Image((u32, u32, Vec<[f32; 4]>)), + Failed, +} + +fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> EnvPixels { let layer = match src { - Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?, - None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?, + Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)), + None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)), }; - let buf = r.blend_layer_buf(ctx, layer)?; + let Some(layer) = layer else { + // A set Source that resolves to no layer (deleted) failed; an unset one with no + // Environment Layer is simply unconfigured. + return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent }; + }; + let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed }; let img = &buf.img; let data = img .data @@ -843,7 +951,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] } }) .collect(); - Some((img.width, img.height, data)) + EnvPixels::Image((img.width, img.height, data)) +} + +/// The default environment when the light has no Source and no Environment Layer: a dim studio +/// gradient, bright above and toward the initial camera, dark below and behind — After +/// Effects "uses a default environment map" for an Environment light too, and its default +/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience +/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which +/// is not what After Effects' default looks like); the light's colour × intensity still scales +/// the overall level, and its Rotation turns the key around the scene (#2436). +pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { + use super::shade::{dot, equirect_dir}; + // The key region's centre: three quarters up, two thirds toward the viewer (the default + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let sa = (v * std::f32::consts::PI).sin(); + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + data.push([l, l, l, 1.0]); + mean += l * sa; + weight += sa; + } + } + // A dim studio: scale the gradient to a weighted mean of exactly 0.3. + let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) } /// Supersampling factor: 2×2 unless draft or very large. @@ -935,10 +1079,23 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; - if let Some((src, k, rot)) = env - && let Some((w, h, d)) = env_pixels(r, ctx, src, lin) - { - add_environment(&mut b, w, h, d, k, rot); + if let Some(env) = env { + match env_pixels(r, ctx, env.src, lin) { + EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + EnvPixels::Absent => { + let (w, h, d) = default_environment(); + add_environment(&mut b, &env, w, h, d) + } + // A selected or found source that produced no pixels (deleted layer, missing + // footage, failed decode): no environment at all — the light contributes + // nothing, exactly like a layer whose source failed renders nothing, never a + // silent swap to the default studio's lighting and shadows (#2436). + EnvPixels::Failed => {} + } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); @@ -947,6 +1104,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: for l in &mut b.s.lights { l.shadow = -1; } + if let Some(e) = &mut b.s.env { + e.shadow = -1; + } } } // Camera matrices. diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 19548b224..820e8fecf 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -382,6 +382,23 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } +/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow +/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant +/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an +/// Environment light's shadow stays partial the way After Effects renders an area-light +/// shadow (a broad environment like the default studio shadows softly, a lone bright spot +/// on black almost fully). A directionless image (or a non-casting light) has no map +/// and shades unshadowed. +pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { + if e.shadow < 0 || e.sun == [0.0; 3] { + return 1.0; + } + let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; + let n_l = dot(n, e.sun).clamp(0.0, 1.0); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); + 1.0 - e.shadow_darkness * e.key * (1.0 - f) +} + /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) { if l.kind == 0 { @@ -529,9 +546,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { let (irr, spec) = env_light(s, e, n, r, rough); let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)]; let (a, b) = env_brdf(n_v, rough); + // The Environment light's shadow darkens its own diffuse and specular. + let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 }; for c in 0..3 { let fs = f0[c] * a + b; - out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao; + out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh; } } let c = add(out, emissive); @@ -557,5 +576,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { } amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } + // The Environment light's shadow counts too (it reaches every surface: no falloff). + if let Some(e) = &s.env { + amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n)); + } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 627e4e291..b2612de98 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -2,7 +2,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build}; use effectcraft_time::{FrameRate, Tick}; -use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map}; +use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map}; use super::shade::{self, PI}; use super::*; use crate::{NoFootage, RenderOpts, Renderer}; @@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() { assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]); } +/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds +/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns +/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow +/// (#2436). +#[test] +fn a_non_finite_environment_has_no_sun_direction() { + let nan = vec![[f32::NAN; 4]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0)); +} + +/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is +/// almost fully directional, and a broad gradient sits in between — the soft studio default +/// shadows visibly but keeps most of its light as fill (#2436). +#[test] +fn environment_key_strength_tracks_directionality() { + let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0); + let mut spot = vec![[0.0f32; 4]; 64 * 32]; + spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; + let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0); + assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); + let (w, h, d) = scene::default_environment(); + let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); +} + +/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at +/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel, +/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no +/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing +/// at the brighter spot. +#[test] +fn opposing_bright_regions_cancel_to_no_shadow() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48 + // sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are + // exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 1.0, 1.0, 1.0]; + opposed[b] = [1.0, 1.0, 1.0, 1.0]; + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // One side twice as bright: the mean points at the brighter spot with its share of the + // weight (key = 1/3 — two parts against one). + let mut tilted = opposed.clone(); + tilted[a] = [2.0, 2.0, 2.0, 1.0]; + let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0); + assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); +} + +/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red +/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the +/// red light's tint removes the blue side's pull, so the mean points straight at the red one +/// (a blue light, at the blue one): the shadow lands where the emitted light comes from. +#[test] +fn a_coloured_light_shadows_from_the_region_it_tints() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two + // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 0.0, 0.0, 1.0]; + opposed[b] = [0.0, 0.0, 1.0, 1.0]; + // To a white light the channel sums are equal: they cancel, no shadow. + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot. + // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same + // way; only the channel ratios matter.) + let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}"); + // A blue light points at the blue one instead. + let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0); + let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the blue side wins: {sun:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -337,6 +421,365 @@ fn cards_and_environment_light() { assert_eq!(lit.data[80 * 120 + 110][3], 0.0); } +/// An Environment light with no Source and no Environment Layer lights the scene with a flat +/// default environment of its colour × intensity — After Effects "uses a default environment +/// map" for an Environment light — not nothing at all (#2436). +#[test] +fn environment_light_without_a_source_lights_the_scene() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + let c = unlit.data[20 * 120 + 30]; + assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}"); + // No Source, no Environment Layer: the light still lights the card (a flat white + // environment at the light's colour × intensity; a card's Material Options shade it). + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {}); + let lit = render(&p, cid); + let c2 = lit.data[20 * 120 + 30]; + assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}"); + // The light's parameters drive it: half intensity darkens towards zero intensity's black. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0)); + let half = render(&p, cid); + let c3 = half.data[20 * 120 + 30]; + assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}"); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0)); + let dark = render(&p, cid); + let c4 = dark.data[20 * 120 + 30]; + assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}"); + // And its colour tints the light red. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0)); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + let red = render(&p, cid); + let c5 = red.data[20 * 120 + 30]; + assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); +} + +/// #2436 review: a selected or auto-picked environment source that fails to render (missing +/// footage, a deleted layer) must not silently swap in the default studio environment — the +/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a +/// light with no Source at all (and no Environment Layer in the comp) uses the default. +#[test] +fn a_failed_environment_source_lights_nothing() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + // The Environment Layer's footage is unreadable (missing; NoFootage serves no frames). + let fid = p.add_item( + "F", + Default::default(), + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "missing.exr".into(), + kind: effectcraft_project::FootageKind::Still, + width: 64, + height: 32, + pixel_aspect: 1.0, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + missing: true, + ..Default::default() + }), + ); + add(&mut p, cid, LayerSource::Footage { item: fid }, |l| { + l.switches.three_d = true; + l.environment = true; + }); + let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id; + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer { + p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap() + } + // Auto-picked Environment Layer, unreadable: the light is off — no studio default, no + // default-environment shadow either. + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing"); + // The light's Source set to that unreadable layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing"); + // The Source pointing at a deleted layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing"); + // Contrast: with no Source and no Environment Layer, the default studio still lights it. + let comp = p.comp_mut(cid).unwrap(); + comp.layers.retain(|l| l.is_light() || !l.environment); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None)); + let lit = render(&p, cid); + let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]); + assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}"); +} + +/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant +/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to +/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below +/// it; Cast Shadows off lights it evenly. +#[test] +fn environment_lights_cast_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a tight bright key above the image's centre, on black — a + // full-comp rect keeps the layer buffer full-size, the radial gradient is the key. + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let key = build::shape_gradient_fill( + ids, + true, + [0.0, -30.0], + [0.0, 5.0], + effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Key", vec![rect, key]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + // Turned 180°, the key shines from up and behind the camera onto the sphere and floor. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/rotation", Value::Scalar(180.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The shadow lands below the sphere on the floor: its core is the darkest floor pixel + // below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded, + // so the ratios below are gamma-compressed (a linear halving reads as ~0.73). + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 25, 75); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 100..150 { + for x in 60..140 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + +/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts +/// nothing — the render is the same as with the option off. +#[test] +fn a_uniform_environment_casts_no_shadow() { + let (mut p, cid) = project(120, 90); + let card = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true); + let env = p.add_item( + "E", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: env }, |l| { + l.environment = true; + l.switches.three_d = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let off = render(&p, cid); + assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit"); + assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); +} + +/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue +/// regions on exactly opposite sides of the image cancel to a white light (it casts no +/// shadow), but the red Environment light's tint removes the blue region's pull, so the red +/// one becomes the key and casts the shadow the user asked for, from the red side. +#[test] +fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a black background with a red square and an equally bright blue + // one at exactly antipodal image positions (the blue is the point mirror of the red through + // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a + // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up, + // left and toward the camera; the blue down, right and away from it. + add(&mut p, cid, LayerSource::Shape, |l| { + l.environment = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + // Contents stack like the AE timeline — the first group is on top — so the background + // goes in last, under the red and blue squares (and its full-comp rect keeps the + // layer buffer full-size: a shape layer's buffer otherwise crops to its content). + let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0); + let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]); + let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0); + let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]); + let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]); + let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]); + let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]); + for g in [red, blue, bg] { + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + }); + // A white light over the chromatic image: the regions cancel — Cast Shadows changes + // nothing, exactly like a directionless image. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let white_casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false)); + let white_off = render(&p, cid); + assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue"); + // The red light: the tint removes the blue region, the red one shadows the sphere onto the + // floor below and right of it (the shadow falls opposite the red region). + let comp = p.comp_mut(cid).unwrap(); + let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap(); + set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + set(light, "lightOptions/castsShadows", Value::Bool(true)); + let img = render(&p, cid); + let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0]; + let side = chan(&img, 25, 75); + let (mut core, mut cx) = (f32::INFINITY, 0usize); + for y in 100..150 { + for x in 60..170 { + if chan(&img, x, y) < core { + core = chan(&img, x, y); + cx = x; + } + } + } + assert!(side > 0.1, "the floor is lit red: {side}"); + assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}"); + assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}"); +} + +/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean +/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out +/// (After Effects' default does not) — and directional: its dominant region points up and +/// toward the initial camera, so a shadow-casting Environment light casts soft, partial +/// shadows out of the box, like After Effects' default. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + mean += (p[0] + p[1] + p[2]) / 3.0 * sa; + weight += sa; + } + assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); + let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!(sun[1] < -0.5, "the key is up: {sun:?}"); + assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); + assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); + assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}"); + // No texel clips past white at the default intensity (the fog of a mean-1 environment). + assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1"); +} + +/// #2436: with no Source and no Environment Layer, the Environment light's default environment +/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows +/// the plane behind it, darkening part of the light while the fill keeps the floor visible +/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights +/// the floor evenly. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The default key (up and toward the camera) drops the shadow below the sphere: the core + // is the darkest floor pixel below it, the lit floor the sample to the left. + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 30, 100); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 110..150 { + for x in 80..120 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + // Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining — + // not the pitch black a point light's shadow would cast at the same darkness. The frame + // is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73). + assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); @@ -1103,3 +1546,54 @@ fn hard_edges_ramp_over_two_texels() { assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); } } + +// ---------------------------------------------------------------- the After Effects look (#2436) + +/// #2436: an Environment light with every option at its default renders like After Effects': +/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the +/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining — +/// not the pitch black a point light casts), and a soft edge in both quality and draft renders +/// (the map's texel grid never shows through as stair-steps). +#[test] +fn environment_shadows_match_the_after_effects_look() { + let (mut p, cid) = project(800, 600); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(1200.0)); + set(l, "geometryOptions/height", Value::Scalar(900.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(80.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0; + for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] { + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // The shadow core: the darkest pixel of the lower half (the key drops the shadow + // below the sphere); the lit floor is the mean of the band to its left. + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 420..580 { + for x in 300..500 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::() / 200.0; + assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}"); + // The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving + // reads as ~0.73): partial means clearly darker than lit, far from black. + assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}"); + // The edge's 10-90 % ramp from the lit plateau (left) down into the shadow. + let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i); + let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32); + assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)"); + } +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index d203cfa71..a5a7c170e 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -523,6 +523,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, extra = ctx .comp .layers + .as_arcs() .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 3dd717aa8..5837f77b8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -185,6 +185,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"), + ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -246,6 +247,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"), + ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"), + ("wiggler", "correlation", "ADBE Vector Correlation"), + ("wiggler", "phase", "ADBE Vector Temporal Phase"), + ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"), + ("wiggler", "seed", "ADBE Vector Random Seed"), + ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ("wiggle", "size", "ADBE Vector Roughen Size"), ("wiggle", "detail", "ADBE Vector Roughen Detail"), @@ -439,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle", + "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index a33714314..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 608c83d0e..2c18039e8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 4760d058b..ddfbc1f3e 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -745,7 +745,8 @@ function __uiMouseWanted(c, inherited) { } // A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, -// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +// 2 right), buttons (held: 1 left, 2 right, 4 middle), detail (click count), screenX, screenY, +// altKey, ctrlKey, metaKey, shiftKey}. function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); @@ -765,6 +766,7 @@ function __uiMouseEvent(type, target, v) { timeStamp: new Date(), detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), button: num(v.button, 0), + buttons: num(v.buttons, 0), clientX: num(v.x, 0), clientY: num(v.y, 0), screenX: num(v.screenX, 0), @@ -910,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) { w.close(v === null ? 2 : v); return; } + // The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script. + if (kind === "resize") { + var nw = Number(v && v[0]), nh = Number(v && v[1]); + var b = w.__ub || w.__lb; + if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) { + w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh]; + __uiLayout(w); + w.__fire("onResizing"); + w.__fire("onResize"); + } + return; + } var c = __uiFind(w, id); if (!c || c.enabled === false) return; var radio = function () { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 875885c11..0af09841a 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -17,6 +17,7 @@ mod include; mod panels; #[cfg(not(windows))] mod services; +mod startup; mod tasks; mod ui; mod ui_more; @@ -371,6 +372,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties. +#[test] +fn wiggle_transform_by_match_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("W", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var root = sh.property("ADBE Root Vectors Group"); + var w = root.addProperty("ADBE Vector Filter - Wiggler"); + var byName = root.addProperty("Wiggle Transform"); + w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]); + [ + w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value, + w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name + ] + "#, + ); + assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"])); + let (_, c) = comp_named(&s, "W"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0])); +} + #[test] fn keyframes_eases_and_interpolation() { let mut s = session(); @@ -1062,6 +1088,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/script/src/tests/startup.rs b/crates/script/src/tests/startup.rs new file mode 100644 index 000000000..67da833c5 --- /dev/null +++ b/crates/script/src/tests/startup.rs @@ -0,0 +1,290 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn startup_scripts_tasks_go_on_after_a_project_opens() { + use web_time::{Duration, Instant}; + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap(); + // A task scheduled after the script's dialog closes. + store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap(); + let mut s = session(&store); + s.execute("file.runStartupScripts", json!({})).unwrap(); + s.execute("scriptui.close", json!({})).unwrap(); + // The launch opens a project after the Startup scripts ran. + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500)); + assert_eq!(r["ran"], json!(2), "{r}"); + // A script run from File ▸ Scripts still stops when the project closes. + s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap(); + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000)); + assert_eq!(r["ran"], json!(2), "{r}"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 082a16cac..ab6f69846 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv // ---------------------------------------------------------------- scheduled tasks +/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window +/// open and is not a Startup script's. +fn ends_with_project(session: &Session, host: u32) -> bool { + !session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host) +} + /// Run the due tasks of the script-host threads; hosts without windows whose project was closed -/// end. +/// end, except Startup scripts' hosts. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { let epoch = session.script_ui.project_epoch; @@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr if let Ok(mut h) = hosts().lock() { for host in session.script_ui.task_hosts.clone() { let Some(end) = h.get(&host) else { continue }; - let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); - if windowless && end.epoch != epoch { + if end.epoch != epoch && ends_with_project(session, host) { // Dropping its end lets the host thread finish. stale.extend(h.remove(&host)); } else if end.next_due.is_some_and(|d| d <= now) { @@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr } /// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those -/// without windows whose project was closed stop. +/// without windows whose project was closed stop, except Startup scripts' hosts. pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { for (host, name, epoch) in crate::tasks::hosts() { if !session.script_ui.task_hosts.contains(&host) { @@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat } let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); - if !alive || (windowless && epoch != session.script_ui.project_epoch) { + if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) { crate::tasks::drop_host(host); if host != CONSOLE_HOST && windowless { drop_inline(host); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a31a17124..5388629cf 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -385,7 +385,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -393,6 +394,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 9a6ba365a..1f7e0d46f 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -203,6 +203,8 @@ fn system_scan_finds_fonts() { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 115c5f33a..79c077b2a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -15,6 +15,7 @@ mod screenshot; mod viewer_frames; mod window; +pub use gpu::{GPU_PREPARING, GpuBuild}; #[cfg(test)] pub(crate) use screenshot::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; @@ -74,8 +75,12 @@ impl EffectcraftApp { wgpu: None, gpu: None, gpu_checked: false, + #[cfg(not(target_arch = "wasm32"))] + gpu_build: None, gpu_failures: None, gpu_failure_applied: None, + footage_watcher: None, + footage_watch_key: None, device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..7796e02ed 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -127,7 +127,7 @@ impl EffectcraftApp { self.now = self.clock.now(); // The compositor exists before the first paint and while hidden (device-loss handling // must run there too). - self.init_gpu(frame); + self.init_gpu(ctx, frame); self.apply_gpu_failure(ctx); if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ctx); @@ -144,6 +144,9 @@ impl EffectcraftApp { // commands handled here use the right catalog already. let language = crate::i18n::language(self); crate::i18n::set_current(language); + // #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the + // status and the menu update even when the window is not painting. + crate::panels::text_panels::poll_font_rescan(self, ctx); if self.styled_language != language { self.want_paint = true; } @@ -384,6 +387,11 @@ impl EffectcraftApp { let full = ui.max_rect(); if self.dialog.is_some() { ui.disable(); + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; @@ -461,6 +469,13 @@ impl EffectcraftApp { self.ui.status.clear(); self.toast = None; } + // "Preparing the GPU compositor…" stays until the compositor is attached (tens of + // seconds on a cold shader cache), not 5 s: `finish_gpu_build` clears it, and the + // worker's repaint brings that frame, so nothing needs to tick meanwhile. A click + // still dismisses it. + if self.ui.status == crate::GPU_PREPARING && self.gpu_initializing() { + return; + } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); if !self.ui.status.is_empty() { let since: f64 = ui.data(|d| d.get_temp(egui::Id::unique("status-since")).unwrap_or(now)); @@ -524,33 +539,40 @@ impl EffectcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(10)); } - self.tick_footage_reload(ctx, now); + self.tick_footage_reload(ctx); } - /// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the - /// window comes back to the front (after saving in another app), stamp the footage files on a - /// worker thread and reload the ones that changed. - fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) { + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: a worker thread stamps the footage + /// files (every two seconds while the window is in front, and as soon as it comes back to + /// the front after saving in another app) and wakes this frame only when a stamp changed; + /// the changed files are reloaded here. Nothing here asks for a repaint: an idle app with + /// unchanged footage stays asleep. + fn tick_footage_reload(&mut self, ctx: &egui::Context) { use effectcraft_engine::footage_reload; - if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) { + if let Some(stamps) = self.footage_watcher.as_ref().and_then(footage_reload::Watcher::take) + && let Err(e) = footage_reload::apply_stamps(&mut self.session, stamps) + { self.ui.status = e.to_string(); } - let (last_key, focus_key) = (egui::Id::unique("footageReload.lastScan"), egui::Id::unique("footageReload.focused")); - let focused = self.focused; - let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); - ctx.data_mut(|d| d.insert_temp(focus_key, focused)); - if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() { + let off = self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty(); + if off { + if let Some(w) = &mut self.footage_watcher { + w.set_watched(Default::default()); + } + self.footage_watch_key = None; return; } - let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0); - if now - last >= 2.0 || (focused && !was_focused) { - ctx.data_mut(|d| d.insert_temp(last_key, now)); - footage_reload::start_scan(&mut self.session); + if self.footage_watcher.is_none() { + let wake = ctx.clone(); + self.footage_watcher = footage_reload::Watcher::start(self.session.services.clone(), move || wake.request_repaint()); } - if self.session.footage_scan.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs(2)); + let Some(watcher) = &mut self.footage_watcher else { return }; + watcher.set_focused(self.focused); + // Which files are watched follows the project and the preference, not the frame. + let key = (self.session.revision, self.session.prefs.import.auto_reload_footage.clone()); + if self.footage_watch_key.as_ref() != Some(&key) { + self.footage_watch_key = Some(key); + watcher.set_watched(footage_reload::watched(&self.session)); } } } diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 9febfa413..225a8727c 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,5 +1,12 @@ use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; +/// The status line while the GPU compositor's worker thread is still building its pipelines +/// (see [`EffectcraftApp::init_gpu`]). +pub const GPU_PREPARING: &str = "Preparing the GPU compositor… (compositing on the CPU until it is ready)"; +/// What the compositor's worker thread hands back: the compositor, why the device can't run it, +/// or the payload of a panic in the graphics backend. +pub type GpuBuild = std::thread::Result>; + impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { RenderSource { @@ -124,7 +131,19 @@ impl EffectcraftApp { /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. - pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { + /// + /// On the desktop the compositor is built on a worker thread. Its compute pipelines (about + /// 235 of them) take tens of seconds to compile on a cold shader cache (first launch, after + /// an OS update, after an update that changed a shader), and eframe shows the window only + /// once the first frame has painted, so building them here kept the window from appearing + /// at all. Until the worker is done the viewer composites on the CPU, as it does without a + /// GPU, and the status bar says so; the browser has no threads and builds it in place. + pub(crate) fn init_gpu(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + #[cfg(not(target_arch = "wasm32"))] + if self.gpu_build.is_some() { + self.finish_gpu_build(frame.wgpu_render_state()); + return; + } if self.gpu_checked { return; } @@ -142,10 +161,80 @@ impl EffectcraftApp { info.device_type, if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } )); + #[cfg(not(target_arch = "wasm32"))] + if self.spawn_gpu_build(ctx, rs) { + return; + } + // In place (the browser, or no thread to be had): nothing to wake later. + let _ = ctx; // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. - let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); - match gpu { + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); + self.attach_gpu(built, Some(rs)); + } + + /// Start building the compositor on a `gpu-compositor` worker thread, which hands its result + /// back to [`Self::finish_gpu_build`] and wakes the UI. False when no thread could be spawned + /// (the caller then builds it in place). + #[cfg(not(target_arch = "wasm32"))] + fn spawn_gpu_build(&mut self, ctx: &egui::Context, rs: &eframe::egui_wgpu::RenderState) -> bool { + let (tx, rx) = std::sync::mpsc::channel(); + let (adapter, device, queue) = (rs.adapter.clone(), rs.device.clone(), rs.queue.clone()); + let gate = self.hooks.before_gpu_build.take(); + let wake = ctx.clone(); + let spawned = std::thread::Builder::new().name("gpu-compositor".into()).spawn(move || { + // Backend validation can panic on shader compilation even when device creation + // succeeded: the panic stays on this thread and arrives as a result. + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(gate) = gate { + gate(); + } + effectcraft_gpu::Gpu::new(&adapter, device, queue) + })); + // No receiver: the window closed, or the device failed, while this was running. + let _ = tx.send(built); + wake.request_repaint(); + }); + if let Err(e) = spawned { + log::warn!("no worker thread for the GPU compositor ({e}); building it on the UI thread"); + return false; + } + self.gpu_build = Some(rx); + self.session.accel_note = Some(GPU_PREPARING.into()); + if self.ui.status.is_empty() { + self.ui.status = GPU_PREPARING.into(); + } + true + } + + /// Take the worker's result when it has arrived ([`Self::init_gpu`]). `rs`: the window's + /// device, which a compositor that built is attached to. + #[cfg(not(target_arch = "wasm32"))] + fn finish_gpu_build(&mut self, rs: Option<&eframe::egui_wgpu::RenderState>) { + let Some(rx) = &self.gpu_build else { return }; + let built = match rx.try_recv() { + Ok(built) => built, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + Err(Box::new("the compositor's worker thread ended without a result") as Box) + } + }; + self.gpu_build = None; + if self.ui.status == GPU_PREPARING { + self.ui.status.clear(); + } + // The device failed while the compositor was being built: it stays detached, and + // `apply_gpu_failure` has already said why the viewer is on the CPU. + if self.gpu_failure_applied.is_some() || self.gpu_failures.as_ref().is_some_and(|bridge| bridge.failure().is_some()) { + return; + } + self.attach_gpu(built, rs); + } + + /// Attach a built compositor to the viewer, or record why there is none. Keeps the document + /// and the egui renderer either way; without a compositor the viewer composites on the CPU. + fn attach_gpu(&mut self, built: GpuBuild, rs: Option<&eframe::egui_wgpu::RenderState>) { + match built { Ok(Ok(g)) => { if let Some(bridge) = &self.gpu_failures { bridge.forward_to(g.context().device_failure_notifier()); @@ -153,7 +242,12 @@ impl EffectcraftApp { return; } } + let Some(rs) = rs else { + self.session.accel_note = Some("GPU compositor unavailable: the window has no wgpu device".into()); + return; + }; log::info!("GPU compositor: {}", effectcraft_engine::render::Accelerator::name(&g)); + self.session.accel_note = None; self.session.accel = Some(Arc::new(g.clone())); self.gpu = Some(g); self.wgpu = Some(rs.clone()); @@ -170,6 +264,19 @@ impl EffectcraftApp { } } + /// Whether the GPU compositor is still being built on its worker thread (the viewer is on + /// the CPU meanwhile). Always false in the browser, which builds it with the first frame. + pub fn gpu_initializing(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.gpu_build.is_some() + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// The viewer shows a frame composited on the GPU (drawn from its wgpu texture). pub fn viewer_on_gpu(&self) -> bool { matches!((&self.viewer_shown, &self.viewer_native), (Some(s), Some(n)) if s.1 == n.1) diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index 10a53f1d2..7326b9f32 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -56,6 +56,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..fbfe4b8af 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -532,7 +532,7 @@ mod tests { let item = p.add_item("long.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "long.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = RenderSource { project: Arc::new(p), diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 6704017b9..f2dc3975d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d17af3b46..483dcf4bf 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -799,7 +799,11 @@ impl DockContent for EffectContent<'_> { } fn logical_label(&self, panel: &PanelKind) -> String { - crate::i18n::command_label("window.panel", panel.title()).to_string() + let title = panel.title(); + // The Window menu names most panels; `Markers` is only in the panel catalog, and the two + // panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::command_label("window.panel", title); + if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() } } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8d7dcb704..457aeb99a 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -62,6 +62,10 @@ pub struct Hooks { /// Tells the user the graphics device was lost and the window is closing, once. `saved` is /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. pub device_lost: Option>)>>, + /// Runs on the worker thread right before the GPU compositor is built (desktop). Tests hold + /// it to look at the interface while the compositor is still being prepared, or make it + /// panic to check that a failing build never reaches the UI thread. + pub before_gpu_build: Option>, } impl Hooks { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 254eec49c..14afe59cc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -15,7 +15,7 @@ pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 11] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; +const CATALOG_CODES: [&str; 12] = ["cs", "de", "es", "fr", "pl", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -66,6 +66,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Polish. +pub(crate) fn polish(app: &EffectcraftApp) -> bool { + language(app) == "pl" +} + /// The UI is in Georgian. pub(crate) fn georgian(app: &EffectcraftApp) -> bool { language(app) == "ka" @@ -134,6 +139,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "pl" => "pl", "ka" => "ka", "ru" => "ru", "de" => "de", @@ -173,6 +179,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "pl" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Pokaż w Eksploratorze", + "Reveal in File Manager" => return "Pokaż w menedżerze plików", + _ => {} + } + } if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { match source { "Reveal in Explorer" => return "Explorer-ში ჩვენება", @@ -196,6 +209,7 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "pl" => POLISH, "ka" => GEORGIAN, _ => return None, }) @@ -266,6 +280,9 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")), "pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")), "cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")), + "fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")), + "de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")), + "pl" => Some(("Cofnij", "Nie można cofnąć", "Ponów", "Nie można ponowić")), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". "ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")), // A colon before the history's (untranslated) action name: "Отменить: Comp1". @@ -306,8 +323,23 @@ pub(crate) fn effect_category(children: &[MenuNode], source: &str) -> Option Option { + let named = shown.strip_prefix("Assign Shortcut to “")?; + match source { + "Assign Shortcut to Workspace" => named.strip_suffix("” Workspace").map(|name| format!("Przypisz skrót do obszaru roboczego „{name}”")), + "Assign Shortcut to 3D View" => named.strip_suffix('”').map(|name| format!("Przypisz skrót do widoku 3D „{name}”")), + _ => None, + } +} + pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "pl" => { + if let Some(label) = polish_assign_shortcut(source, &shown) { + return label; + } + } "cs" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -836,6 +868,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), + ("keys.setMuted", "Mute Keyframes", "静音关键帧"), + ("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1472,6 +1506,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), + ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), + ("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2105,6 +2141,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), + ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2741,6 +2779,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"), ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), + ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), + ("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3378,6 +3418,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), + ("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4013,6 +4055,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), + ("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4655,6 +4699,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), + ("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5290,6 +5336,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), + ("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5925,6 +5973,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), + ("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6124,6 +6174,640 @@ const GERMAN: &[(&str, &str, &str)] = &[ // <<< generated i18n catalogs +/// Polish (pl): original phrase translations of every fixed menu entry. +const POLISH: &[(&str, &str, &str)] = &[ + ("", "Front", "Przód"), + ("", "Bevel", "Fazowanie"), + ("", "Side", "Bok"), + ("", "Back", "Tył"), + ("layer.addTextAnimator", "Color", "Kolor"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O programie EffectCraft..."), + ("", "Settings...", "Ustawienia..."), + ("app.settings", "General...", "Ogólne..."), + ("app.settings", "Startup & Repair...", "Uruchamianie i naprawa..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozycja..."), + ("app.settings", "Previews...", "Podglądy..."), + ("app.settings", "Appearance...", "Wygląd..."), + ("app.settings", "Grids & Guides...", "Siatki i linie pomocnicze..."), + ("app.settings", "Labels...", "Etykiety..."), + ("app.settings", "Type...", "Tekst..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Eksport..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Dysk..."), + ("app.settings", "Memory & CPU...", "Pamięć i procesor..."), + ("app.settings", "Video...", "Wideo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skrypty i wyrażenia..."), + ("app.hide", "Hide EffectCraft", "Ukryj EffectCraft"), + ("app.hideOthers", "Hide Others", "Ukryj pozostałe"), + ("app.showAll", "Show All", "Pokaż wszystko"), + ("app.quit", "Quit EffectCraft", "Zakończ EffectCraft"), + ("", "File", "Plik"), + ("", "New", "Nowy"), + ("file.newProject", "New Project", "Nowy projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nowy projekt z szablonu..."), + ("project.newFolder", "New Folder", "Nowy folder"), + ("file.open", "Open Project...", "Otwórz projekt..."), + ("", "Open Recent", "Otwórz ostatnie"), + ("file.clearRecent", "Clear Recent Projects", "Wyczyść ostatnie projekty"), + ("file.close", "Close", "Zamknij"), + ("file.closeProject", "Close Project", "Zamknij projekt"), + ("file.save", "Save", "Zapisz"), + ("", "Save As", "Zapisz jako"), + ("file.saveAs", "Save As...", "Zapisz jako..."), + ("file.saveCopy", "Save a Copy...", "Zapisz kopię..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Zapisz kopię jako XML..."), + ("templates.saveAs", "Save as Template...", "Zapisz jako szablon..."), + ("file.incrementAndSave", "Increment and Save", "Zapisz z kolejnym numerem"), + ("file.revert", "Revert", "Przywróć"), + ("", "Import", "Importuj"), + ("file.import", "File...", "Plik..."), + ("file.importMultiple", "Multiple Files...", "Wiele plików..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), + ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.importSolid", "Solid...", "Plansza..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Punkt zbiegu (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("", "Import Recent Footage", "Importuj ostatnie materiały"), + ("file.clearRecentFootage", "Clear Recent Footage", "Wyczyść ostatnie materiały"), + ("", "Export", "Eksportuj"), + ("renderQueue.add", "Add to Render Queue", "Dodaj do kolejki renderowania"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("app.find", "Find", "Znajdź"), + ("layer.addItem", "Add Footage to Comp", "Dodaj materiał do kompozycji"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nowa kompozycja z zaznaczenia..."), + ("", "Dependencies", "Zależności"), + ("file.collectFiles", "Collect Files...", "Zbierz pliki..."), + ("file.consolidateFootage", "Consolidate All Footage", "Scal wszystkie materiały"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Usuń nieużywane materiały"), + ("file.reduceProject", "Reduce Project", "Zredukuj projekt"), + ("file.findMissing", "Find Missing Effects", "Znajdź brakujące efekty"), + ("file.findMissing", "Find Missing Fonts", "Znajdź brakujące czcionki"), + ("file.findMissing", "Find Missing Footage", "Znajdź brakujące materiały"), + ("file.relinkFootage", "Relink Missing Footage...", "Połącz ponownie brakujące materiały..."), + ("file.watchFolder", "Watch Folder...", "Folder obserwowany..."), + ("", "Scripts", "Skrypty"), + ("file.installScript", "Install Script File...", "Zainstaluj plik skryptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Zainstaluj panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Uruchom plik skryptu..."), + ("", "Create Proxy", "Utwórz proxy"), + ("file.createProxy", "Still...", "Klatka..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Ustaw proxy"), + ("file.setProxy", "File...", "Plik..."), + ("file.setProxyNone", "None", "Brak"), + ("", "Interpret Footage", "Interpretuj materiał"), + ("file.interpretFootage", "Main...", "Główny..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamiętaj interpretację"), + ("file.applyInterpretation", "Apply Interpretation", "Zastosuj interpretację"), + ("", "Replace Footage", "Zastąp materiał"), + ("file.replaceFootage", "File...", "Plik..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.replaceWithSolid", "Solid...", "Plansza..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Z warstwową kompozycją"), + ("file.reloadFootage", "Reload Footage", "Wczytaj ponownie materiał"), + ("file.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("file.projectSettings", "Project Settings...", "Ustawienia projektu..."), + ("app.quit", "Exit", "Zakończ"), + ("", "Edit", "Edycja"), + ("edit.undo", "Undo", "Cofnij"), + ("edit.redo", "Redo", "Ponów"), + ("", "History", "Historia"), + ("app.commandPalette", "Quick Apply...", "Szybkie stosowanie..."), + ("edit.cut", "Cut", "Wytnij"), + ("edit.copy", "Copy", "Kopiuj"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopiuj z łączami właściwości"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopiuj ze względnymi łączami właściwości"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopiuj tylko wyrażenie"), + ("edit.paste", "Paste", "Wklej"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Wklej odwrócone klatki kluczowe"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Wklej tekst i dopasuj formatowanie"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Wklej tylko formatowanie tekstu"), + ("edit.clear", "Clear", "Wyczyść"), + ("edit.duplicate", "Duplicate", "Powiel"), + ("edit.splitLayer", "Split Layer", "Podziel warstwę"), + ("edit.liftWorkArea", "Lift Work Area", "Wytnij obszar roboczy"), + ("edit.extractWorkArea", "Extract Work Area", "Wyodrębnij obszar roboczy"), + ("edit.selectAll", "Select All", "Zaznacz wszystko"), + ("edit.deselectAll", "Deselect All", "Usuń zaznaczenie"), + ("", "Label", "Etykieta"), + ("edit.selectLabelGroup", "Select Label Group", "Zaznacz grupę etykiet"), + ("edit.label", "None", "Brak"), + ("edit.label", "Red", "Czerwony"), + ("edit.label", "Yellow", "Żółty"), + ("edit.label", "Aqua", "Akwamaryna"), + ("edit.label", "Pink", "Różowy"), + ("edit.label", "Lavender", "Lawendowy"), + ("edit.label", "Peach", "Brzoskwiniowy"), + ("edit.label", "Sea Foam", "Morska piana"), + ("edit.label", "Blue", "Niebieski"), + ("edit.label", "Green", "Zielony"), + ("edit.label", "Purple", "Fioletowy"), + ("edit.label", "Orange", "Pomarańczowy"), + ("edit.label", "Brown", "Brązowy"), + ("edit.label", "Fuchsia", "Fuksja"), + ("edit.label", "Cyan", "Cyjan"), + ("edit.label", "Sandstone", "Piaskowiec"), + ("edit.label", "Dark Green", "Ciemnozielony"), + ("app.settings", "Edit Label Colors...", "Edytuj kolory etykiet..."), + ("", "Select Keyframe Label Group", "Zaznacz grupę etykiet klatek kluczowych"), + ("keys.selectLabelGroup", "On Selected Layers", "Na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "On All Layers", "Na wszystkich warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Widoczne klatki kluczowe na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Widoczne klatki kluczowe na wszystkich warstwach"), + ("", "Purge", "Wyczyść pamięć"), + ("edit.purge", "All Cache...", "Całą pamięć podręczną..."), + ("edit.purge", "All Memory & Disk Cache...", "Całą pamięć i pamięć dyskową..."), + ("edit.purge", "All Memory", "Całą pamięć"), + ("edit.purge", "All Disk Cache...", "Całą pamięć dyskową..."), + ("edit.purge", "All 3D Cache...", "Całą pamięć 3D..."), + ("edit.purgeUndo", "Undo", "Cofanie"), + ("edit.purge", "Image Cache Memory", "Pamięć podręczna obrazów"), + ("edit.purge", "Snapshot", "Migawka"), + ("edit.editOriginal", "Edit Original...", "Edytuj oryginał..."), + ("", "Templates", "Szablony"), + ("app.templates", "Render Settings...", "Ustawienia renderowania..."), + ("app.templates", "Output Module...", "Moduł wyjściowy..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Skróty klawiaturowe"), + ("", "Preferences", "Preferencje"), + ("", "Composition", "Kompozycja"), + ("comp.new", "New Composition...", "Nowa kompozycja..."), + ("comp.settings", "Composition Settings...", "Ustawienia kompozycji..."), + ("comp.setPosterTime", "Set Poster Time", "Ustaw czas plakatu"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Przytnij kompozycję do obszaru roboczego"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Kadruj kompozycję do obszaru zainteresowania"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Kadruj kompozycję do granic zaznaczonych warstw (warstwy)"), + ("render.addOutputModule", "Add Output Module", "Dodaj moduł wyjściowy"), + ("", "Preview", "Podgląd"), + ("playback.toggle", "Play Current Preview", "Odtwórz bieżący podgląd"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Zapisz klatkę jako"), + ("comp.saveFrameAs", "File...", "Plik..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Warstwy Photoshopa..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopiuj klatkę do schowka"), + ("render.preRender", "Pre-render...", "Wstępne renderowanie..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Zapisz bieżący podgląd..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otwórz w Essential Graphics"), + ("", "Responsive Design — Time", "Projekt responsywny — czas"), + ("comp.responsiveTime", "Create Intro", "Utwórz intro"), + ("comp.responsiveTime", "Create Outro", "Utwórz outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Utwórz chroniony obszar z obszaru roboczego"), + ("comp.flowchart", "Composition Flowchart", "Schemat kompozycji"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Utwórz środowisko VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Wyodrębnij cubemapę..."), + ("", "Layer", "Warstwa"), + ("layer.newText", "Text", "Tekst"), + ("layer.newSolid", "Solid...", "Plansza..."), + ("layer.newLight", "Light...", "Światło..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Obiekt zerowy"), + ("layer.newShape", "Shape Layer", "Warstwa kształtu"), + ("layer.newAdjustment", "Adjustment Layer", "Warstwa korekcyjna"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Warstwa wypełnienia z uwzględnieniem zawartości..."), + ("layer.new3dPrimitive", "Cube", "Sześcian"), + ("layer.new3dPrimitive", "Sphere", "Kula"), + ("layer.new3dPrimitive", "Plane", "Płaszczyzna"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Stożek"), + ("layer.new3dPrimitive", "Cylinder", "Walec"), + ("layer.settings", "Layer Settings...", "Ustawienia warstwy..."), + ("layer.openLayer", "Open Layer", "Otwórz warstwę"), + ("layer.openSource", "Open Layer Source", "Otwórz źródło warstwy"), + ("layer.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nowa maska"), + ("layer.mask.shape", "Mask Shape...", "Kształt maski..."), + ("layer.mask.set", "Mask Feather...", "Wtapianie maski..."), + ("layer.mask.set", "Mask Opacity...", "Krycie maski..."), + ("layer.mask.set", "Mask Expansion...", "Rozszerzenie maski..."), + ("layer.mask.reset", "Reset Mask", "Resetuj maskę"), + ("layer.mask.remove", "Remove Mask", "Usuń maskę"), + ("layer.mask.removeAll", "Remove All Masks", "Usuń wszystkie maski"), + ("track.mask", "Track Mask", "Śledź maskę"), + ("", "Mode", "Tryb"), + ("layer.mask.mode", "None", "Brak"), + ("layer.mask.mode", "Add", "Dodaj"), + ("layer.mask.mode", "Subtract", "Odejmij"), + ("layer.mask.mode", "Intersect", "Część wspólna"), + ("layer.mask.mode", "Lighten", "Rozjaśnij"), + ("layer.mask.mode", "Darken", "Przyciemnij"), + ("layer.mask.mode", "Difference", "Różnica"), + ("layer.mask.invert", "Inverted", "Odwrócona"), + ("layer.mask.lock", "Locked", "Zablokowana"), + ("", "Motion Blur", "Rozmycie ruchu"), + ("layer.mask.motionBlur", "Same As Layer", "Jak warstwa"), + ("layer.mask.motionBlur", "On", "Wł."), + ("layer.mask.motionBlur", "Off", "Wył."), + ("", "Feather Falloff", "Zanikanie wtapiania"), + ("layer.mask.featherFalloff", "Smooth", "Gładkie"), + ("layer.mask.featherFalloff", "Linear", "Liniowe"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odblokuj wszystkie maski"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zablokuj pozostałe maski"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"), + ("", "Mask and Shape Path", "Ścieżka maski i kształtu"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Zamknięta"), + ("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"), + ("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"), + ("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"), + ("", "Quality", "Jakość"), + ("layer.quality", "Best", "Najlepsza"), + ("layer.quality", "Draft", "Robocza"), + ("layer.quality", "Wireframe", "Szkielet"), + ("layer.sampling", "Bilinear", "Dwuliniowa"), + ("layer.sampling", "Bicubic", "Dwusześcienna"), + ("", "Switches", "Przełączniki"), + ("layer.hideOtherVideo", "Hide Other Video", "Ukryj pozostałe wideo"), + ("layer.showAllVideo", "Show All Video", "Pokaż całe wideo"), + ("layer.unlockAll", "Unlock All Layers", "Odblokuj wszystkie warstwy"), + ("layer.expressions", "Enable Expressions", "Włącz wyrażenia"), + ("layer.expressions", "Disable Expressions", "Wyłącz wyrażenia"), + ("layer.setSwitch", "Shy", "Nieśmiała"), + ("layer.setSwitch", "Lock", "Zablokuj"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Wideo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Zwiń"), + ("layer.setSwitch", "Motion Blur", "Rozmycie ruchu"), + ("layer.setSwitch", "Adjustment Layer", "Warstwa korekcyjna"), + ("", "Transform", "Przekształć"), + ("layer.transform", "Reset", "Resetuj"), + ("layer.setTransform", "Anchor Point...", "Punkt zakotwiczenia..."), + ("layer.setTransform", "Position...", "Położenie..."), + ("layer.setTransform", "Scale...", "Skala..."), + ("layer.setTransform", "Orientation...", "Orientacja..."), + ("layer.setTransform", "Rotation...", "Obrót..."), + ("layer.setTransform", "Opacity...", "Krycie..."), + ("layer.transform", "Flip Horizontal", "Odbij w poziomie"), + ("layer.transform", "Flip Vertical", "Odbij w pionie"), + ("layer.transform", "Center In View", "Wyśrodkuj w widoku"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Wyśrodkuj punkt zakotwiczenia w zawartości warstwy"), + ("layer.transform", "Fit to Comp", "Dopasuj do kompozycji"), + ("layer.transform", "Fit to Comp Width", "Dopasuj do szerokości kompozycji"), + ("layer.transform", "Fit to Comp Height", "Dopasuj do wysokości kompozycji"), + ("layer.autoOrient", "Auto-Orient...", "Automatyczna orientacja..."), + ("", "Time", "Czas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Włącz przemapowanie czasu"), + ("layer.timeReverse", "Time-Reverse Layer", "Odwróć czas warstwy"), + ("layer.timeStretch", "Time Stretch...", "Rozciągnięcie czasu..."), + ("layer.freezeFrame", "Freeze Frame", "Zamrożenie klatki"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zamroź na ostatniej klatce"), + ("layer.alignVideoToData", "Align Video to Data", "Wyrównaj wideo do danych"), + ("", "Frame Blending", "Mieszanie klatek"), + ("layer.frameBlending", "Off", "Wył."), + ("layer.frameBlending", "Frame Mix", "Miks klatek"), + ("layer.frameBlending", "Pixel Motion", "Ruch pikseli"), + ("layer.setSwitch", "3D Layer", "Warstwa 3D"), + ("layer.setSwitch", "Guide Layer", "Warstwa pomocnicza"), + ("layer.environment", "Environment Layer", "Warstwa środowiska"), + ("", "Markers", "Znaczniki"), + ("layer.addMarker", "Add Marker", "Dodaj znacznik"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizuj znaczniki ze źródła"), + ("layer.markersLock", "Lock Markers", "Zablokuj znaczniki"), + ("layer.deleteAllMarkers", "Delete All Markers", "Usuń wszystkie znaczniki"), + ("layer.setSwitch", "Preserve Transparency", "Zachowaj przezroczystość"), + ("", "Blending Mode", "Tryb mieszania"), + ("layer.setBlendMode", "Normal", "Normalny"), + ("layer.setBlendMode", "Dissolve", "Rozpuszczenie"), + ("layer.setBlendMode", "Dancing Dissolve", "Tańczące rozpuszczenie"), + ("layer.setBlendMode", "Darken", "Ciemniej"), + ("layer.setBlendMode", "Multiply", "Mnożenie"), + ("layer.setBlendMode", "Color Burn", "Ściemnianie koloru"), + ("layer.setBlendMode", "Classic Color Burn", "Klasyczne ściemnianie koloru"), + ("layer.setBlendMode", "Linear Burn", "Ściemnianie liniowe"), + ("layer.setBlendMode", "Darker Color", "Ciemniejszy kolor"), + ("layer.setBlendMode", "Add", "Dodaj"), + ("layer.setBlendMode", "Lighten", "Jaśniej"), + ("layer.setBlendMode", "Screen", "Mnożenie odwrotne"), + ("layer.setBlendMode", "Color Dodge", "Rozjaśnianie koloru"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasyczne rozjaśnianie koloru"), + ("layer.setBlendMode", "Linear Dodge", "Rozjaśnianie liniowe"), + ("layer.setBlendMode", "Lighter Color", "Jaśniejszy kolor"), + ("layer.setBlendMode", "Overlay", "Nakładka"), + ("layer.setBlendMode", "Soft Light", "Łagodne światło"), + ("layer.setBlendMode", "Hard Light", "Ostre światło"), + ("layer.setBlendMode", "Linear Light", "Światło liniowe"), + ("layer.setBlendMode", "Vivid Light", "Jaskrawe światło"), + ("layer.setBlendMode", "Pin Light", "Światło punktowe"), + ("layer.setBlendMode", "Hard Mix", "Twarde mieszanie"), + ("layer.setBlendMode", "Difference", "Różnica"), + ("layer.setBlendMode", "Classic Difference", "Klasyczna różnica"), + ("layer.setBlendMode", "Exclusion", "Wykluczenie"), + ("layer.setBlendMode", "Subtract", "Odejmowanie"), + ("layer.setBlendMode", "Divide", "Dzielenie"), + ("layer.setBlendMode", "Hue", "Barwa"), + ("layer.setBlendMode", "Saturation", "Nasycenie"), + ("layer.setBlendMode", "Color", "Kolor"), + ("layer.setBlendMode", "Luminosity", "Jasność"), + ("layer.setBlendMode", "Stencil Alpha", "Szablon alfa"), + ("layer.setBlendMode", "Stencil Luma", "Szablon luma"), + ("layer.setBlendMode", "Silhouette Alpha", "Sylweta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Sylweta luma"), + ("layer.setBlendMode", "Alpha Add", "Dodawanie alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescencja premul"), + ("layer.setBlendMode", "Next Blending Mode", "Następny tryb mieszania"), + ("layer.setBlendMode", "Previous Blending Mode", "Poprzedni tryb mieszania"), + ("", "Track Matte", "Matowa ścieżka"), + ("layer.trackMatte", "No Track Matte", "Bez matowej ścieżki"), + ("layer.trackMatte", "Alpha Matte", "Matowa alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Odwrócona matowa alfa"), + ("layer.trackMatte", "Luma Matte", "Matowa luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Odwrócona matowa luma"), + ("layer.trackMatte", "Matte with Layer Above", "Matowa z warstwą powyżej"), + ("layer.trackMatte", "Matte with Layer Below", "Matowa z warstwą poniżej"), + ("", "Layer Styles", "Style warstwy"), + ("layer.style.options", "Layer Style Options...", "Opcje stylu warstwy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Konwertuj na edytowalne style"), + ("layer.style.showAll", "Show All", "Pokaż wszystkie"), + ("layer.style.removeAll", "Remove All", "Usuń wszystkie"), + ("layer.style.dropShadow", "Drop Shadow", "Cień"), + ("layer.style.innerShadow", "Inner Shadow", "Cień wewnętrzny"), + ("layer.style.outerGlow", "Outer Glow", "Blask zewnętrzny"), + ("layer.style.innerGlow", "Inner Glow", "Blask wewnętrzny"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Faza i płaskorzeźba"), + ("layer.style.satin", "Satin", "Satyna"), + ("layer.style.colorOverlay", "Color Overlay", "Nakładka koloru"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Nakładka gradientu"), + ("layer.style.stroke", "Stroke", "Obrys"), + ("path.groupShapes", "Group Shapes", "Grupuj kształty"), + ("path.ungroupShapes", "Ungroup Shapes", "Rozgrupuj kształty"), + ("", "Arrange", "Kolejność"), + ("layer.arrange", "Bring Layer to Front", "Przesuń warstwę na wierzch"), + ("layer.arrange", "Bring Layer Forward", "Przesuń warstwę do przodu"), + ("layer.arrange", "Send Layer Backward", "Przesuń warstwę do tyłu"), + ("layer.arrange", "Send Layer to Back", "Przesuń warstwę na spód"), + ("", "Reveal", "Pokaż"), + ("layer.revealSource", "Reveal Layer Source in Project", "Pokaż źródło warstwy w projekcie"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Pokaż warstwę w schemacie projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Pokaż kompozycję w projekcie"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Pokaż błędy wyrażeń"), + ("", "Create", "Utwórz"), + ("layer.create", "Convert to Editable Text", "Konwertuj na edytowalny tekst"), + ("layer.create", "Create Shapes from Text", "Utwórz kształty z tekstu"), + ("layer.create", "Create Masks from Text", "Utwórz maski z tekstu"), + ("layer.create", "Create Shapes from Vector Layer", "Utwórz kształty z warstwy wektorowej"), + ("layer.create", "Create Keyframes from Data", "Utwórz klatki kluczowe z danych"), + ("layer.create", "Null Controllers for Positional Points", "Kontrolery zerowe dla punktów położenia"), + ("layer.create", "Null Controllers for Path Points", "Kontrolery zerowe dla punktów ścieżki"), + ("layer.create", "Nulls Following Path Points", "Obiekty zerowe podążające za punktami ścieżki"), + ("layer.create", "Null Tracing Along Path", "Obiekt zerowy wzdłuż ścieżki"), + ("layer.create", "Create 3D Layer Instance", "Utwórz instancję warstwy 3D"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Utwórz kamerę z widoku 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Utwórz zestaw stereo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Utwórz obiekt zerowy orbity"), + ("camera.fromModel", "Create Cameras from 3D Model", "Utwórz kamery z modelu 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Powiąż odległość ostrości z punktem zainteresowania"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Powiąż odległość ostrości z warstwą"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Ustaw odległość ostrości na warstwę"), + ("layer.cameraSettings", "Camera Settings...", "Ustawienia kamery..."), + ("view.reset3DView", "Reset 3D View", "Resetuj widok 3D"), + ("", "Light", "Światło"), + ("light.fromModel", "Create Lights from 3D Model", "Utwórz światła z modelu 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Steruj światłem za pomocą kamery"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Utwórz warstwę tła światła otoczenia"), + ("", "Material", "Materiał"), + ("material.revealSource", "Reveal Material Source in Project", "Pokaż źródło materiału w projekcie"), + ("material.reset", "Reset Material", "Resetuj materiał"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Powiel i przypisz materiał"), + ("layer.autoTrace", "Auto-trace...", "Automatyczne obrysowanie..."), + ("layer.precompose", "Pre-compose...", "Utwórz kompozycję wstępną..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Wykrywanie zmian scen..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ustawienia efektów"), + ("effect.applyLast", "Last Effect", "Ostatni efekt"), + ("effect.removeAll", "Remove All", "Usuń wszystkie"), + ("effect.manage", "Manage Effects...", "Zarządzaj efektami..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Wczytaj wtyczkę efektu..."), + ("", "Animation", "Animacja"), + ("anim.savePreset", "Save Animation Preset...", "Zapisz ustawienie animacji..."), + ("anim.applyPreset", "Apply Animation Preset...", "Zastosuj ustawienie animacji..."), + ("", "Recent Animation Presets", "Ostatnie ustawienia animacji"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Wyczyść ostatnie ustawienia"), + ("anim.browsePresets", "Browse Presets...", "Przeglądaj ustawienia..."), + ("", "Text Animation Presets", "Ustawienia animacji tekstu"), + ("layer.applyTextPreset", "Typewriter", "Maszyna do pisania"), + ("layer.applyTextPreset", "Fade Up Characters", "Pojawianie się znaków"), + ("layer.applyTextPreset", "Bounce In Words", "Odbijające się słowa"), + ("layer.applyTextPreset", "Tracking In", "Zbliżanie liter"), + ("layer.applyTextPreset", "Scramble", "Mieszanie znaków"), + ("layer.applyTextPreset", "Blur In", "Wyostrzanie z rozmycia"), + ("layer.applyTextPreset", "Jitter", "Drganie"), + ("layer.applyTextPreset", "Drop In Lines", "Wpadające wiersze"), + ("anim.addKeyframe", "Add Keyframe", "Dodaj klatkę kluczową"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Przełącz klatkę zatrzymania"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolacja klatek kluczowych..."), + ("keys.velocity", "Keyframe Velocity...", "Prędkość klatek kluczowych..."), + ("", "Keyframe Assistant", "Asystent klatek kluczowych"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Konwertuj audio na klatki kluczowe"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Konwertuj wyrażenie na klatki kluczowe"), + ("keys.easyEase", "Easy Ease", "Łagodne przejście"), + ("keys.easyEaseIn", "Easy Ease In", "Łagodne wejście"), + ("keys.easyEaseOut", "Easy Ease Out", "Łagodne wyjście"), + ("keys.exponentialScale", "Exponential Scale", "Skalowanie wykładnicze"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Ułóż warstwy w sekwencję..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Odwróć czas klatek kluczowych"), + ("", "Animate Text", "Animuj tekst"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("layer.addTextAnimator", "Anchor Point", "Punkt zakotwiczenia"), + ("layer.addTextAnimator", "Position", "Położenie"), + ("layer.addTextAnimator", "Scale", "Skala"), + ("layer.addTextAnimator", "Skew", "Pochylenie"), + ("layer.addTextAnimator", "Rotation", "Obrót"), + ("layer.addTextAnimator", "Opacity", "Krycie"), + ("layer.addTextAnimator", "All Transform Properties", "Wszystkie właściwości przekształcenia"), + ("", "Fill Color", "Kolor wypełnienia"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Barwa"), + ("layer.addTextAnimator", "Saturation", "Nasycenie"), + ("layer.addTextAnimator", "Brightness", "Jasność"), + ("", "Stroke Color", "Kolor obrysu"), + ("layer.addTextAnimator", "Stroke Width", "Szerokość obrysu"), + ("layer.addTextAnimator", "Tracking", "Światło"), + ("layer.addTextAnimator", "Line Anchor", "Zakotwiczenie wiersza"), + ("layer.addTextAnimator", "Line Spacing", "Odstęp między wierszami"), + ("layer.addTextAnimator", "Character Offset", "Przesunięcie znaku"), + ("layer.addTextAnimator", "Character Value", "Wartość znaku"), + ("layer.addTextAnimator", "Blur", "Rozmycie"), + ("text.animatorFontAxes", "Variable Font Axes", "Osie czcionki zmiennej"), + ("", "Add Text Selector", "Dodaj selektor tekstu"), + ("text.addSelector", "Range", "Zakres"), + ("text.addSelector", "Wiggly", "Falowanie"), + ("text.addSelector", "Expression", "Wyrażenie"), + ("text.removeAllAnimators", "Remove All Text Animators", "Usuń wszystkie animatory tekstu"), + ("prop.setExpression", "Add Expression", "Dodaj wyrażenie"), + ("essential.addProperty", "Add Property to Essential Graphics", "Dodaj właściwość do Essential Graphics"), + ("prop.separateDimensions", "Separate Dimensions", "Rozdziel wymiary"), + ("track.camera", "Track Camera", "Śledź kamerę"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "Śledź ruch"), + ("track.stabilize", "Stabilize Motion", "Stabilizuj ruch"), + ("track.property", "Track this Property", "Śledź tę właściwość"), + ("anim.reveal", "Reveal Properties with Keyframes", "Pokaż właściwości z klatkami kluczowymi"), + ("anim.reveal", "Reveal Properties with Animation", "Pokaż właściwości z animacją"), + ("anim.reveal", "Reveal All Modified Properties", "Pokaż wszystkie zmienione właściwości"), + ("", "View", "Widok"), + ("view.newViewer", "New Viewer", "Nowa przeglądarka"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Podziel z nową zablokowaną przeglądarką"), + ("view.zoomIn", "Zoom In", "Powiększ"), + ("view.zoomOut", "Zoom Out", "Pomniejsz"), + ("", "Resolution", "Rozdzielczość"), + ("view.res.full", "Full", "Pełna"), + ("view.res.half", "Half", "Połowa"), + ("view.res.third", "Third", "Jedna trzecia"), + ("view.res.quarter", "Quarter", "Jedna czwarta"), + ("view.res.custom", "Custom...", "Niestandardowa..."), + ("view.displayColorManagement", "Use Display Color Management", "Używaj zarządzania kolorem wyświetlacza"), + ("", "Simulate Output", "Symuluj wyjście"), + ("view.simulateOutput", "No Output Simulation", "Bez symulacji wyjścia"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starsze RGB komputerów Macintosh (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Standardowe internetowe RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Liniowe (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Moje niestandardowe RGB..."), + ("view.simulateOutput", "Custom...", "Niestandardowe..."), + ("view.rulers", "Show Rulers", "Pokaż linijki"), + ("", "Panel Background Color", "Kolor tła panelu"), + ("view.panelBackground", "Black", "Czarny"), + ("view.panelBackground", "Dark Gray", "Ciemnoszary"), + ("view.panelBackground", "Medium Gray (Default)", "Średnioszary (domyślny)"), + ("view.panelBackground", "Light Gray", "Jasnoszary"), + ("view.panelBackground", "White", "Biały"), + ("view.panelBackground", "Custom", "Niestandardowy"), + ("view.panelBackground", "Select Custom Background Color...", "Wybierz niestandardowy kolor tła..."), + ("view.guides", "Show Guides", "Pokaż linie pomocnicze"), + ("view.snapToGuides", "Snap to Guides", "Przyciągaj do linii pomocniczych"), + ("view.lockGuides", "Lock Guides", "Zablokuj linie pomocnicze"), + ("view.addGuide", "Add Guide...", "Dodaj linię pomocniczą..."), + ("view.addPercentGuide", "Add Percentage Guide...", "Dodaj procentową linię pomocniczą..."), + ("view.convertGuides", "Convert Guides to Percentage", "Konwertuj linie pomocnicze na procenty"), + ("view.convertGuides", "Convert Guides to Pixels", "Konwertuj linie pomocnicze na piksele"), + ("view.clearGuides", "Clear Guides", "Wyczyść linie pomocnicze"), + ("view.importGuides", "Import Guides...", "Importuj linie pomocnicze..."), + ("view.exportGuides", "Export Guides...", "Eksportuj linie pomocnicze..."), + ("view.grid", "Show Grid", "Pokaż siatkę"), + ("view.snapToGrid", "Snap to Grid", "Przyciągaj do siatki"), + ("view.snapping", "Snapping", "Przyciąganie"), + ("view.options", "View Options...", "Opcje widoku..."), + ("view.layerControls", "Show Layer Controls", "Pokaż elementy sterujące warstwy"), + ("", "Switch View Layout", "Przełącz układ widoków"), + ("view.layout", "1 View", "1 widok"), + ("view.layout", "2 Views", "2 widoki"), + ("view.layout", "4 Views", "4 widoki"), + ("view.shareViewOptions", "Share View Options", "Wspólne opcje widoku"), + ("", "Switch 3D View", "Przełącz widok 3D"), + ("view.3d.activeCamera", "Active Camera", "Aktywna kamera"), + ("view.3d.default", "Default", "Domyślny"), + ("view.3d.front", "Front", "Przód"), + ("view.3d.left", "Left", "Lewo"), + ("view.3d.top", "Top", "Góra"), + ("view.3d.back", "Back", "Tył"), + ("view.3d.right", "Right", "Prawo"), + ("view.3d.bottom", "Bottom", "Dół"), + ("view.3d.custom1", "Custom View 1", "Widok niestandardowy 1"), + ("view.3d.custom2", "Custom View 2", "Widok niestandardowy 2"), + ("view.3d.custom3", "Custom View 3", "Widok niestandardowy 3"), + ("", "Assign Shortcut to 3D View", "Przypisz skrót do widoku 3D"), + ("view.3d.last", "Switch to Last 3D View", "Przełącz na ostatni widok 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Patrz na zaznaczone warstwy"), + ("view.lookAtAll", "Look at All Layers", "Patrz na wszystkie warstwy"), + ("time.set", "Go to Time...", "Przejdź do czasu..."), + ("view.fullScreen", "Enter Full Screen", "Pełny ekran"), + ("", "Window", "Okno"), + ("", "Workspace", "Obszar roboczy"), + ("window.workspace", "Default", "Domyślny"), + ("window.workspace", "Review", "Przegląd"), + ("window.workspace", "Learn", "Nauka"), + ("window.workspace", "Small Screen", "Mały ekran"), + ("window.workspace", "Standard", "Standardowy"), + ("window.workspace", "All Panels", "Wszystkie panele"), + ("window.workspace", "Animation", "Animacja"), + ("window.workspace", "Color", "Kolor"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Essential Graphics"), + ("window.workspace", "Minimal", "Minimalny"), + ("window.workspace", "Motion Tracking", "Śledzenie ruchu"), + ("window.workspace", "Paint", "Malowanie"), + ("window.workspace", "Text", "Tekst"), + ("window.workspace", "Undocked Panels", "Panele odpięte"), + ("window.resetWorkspace", "Reset to Saved Layout", "Przywróć zapisany układ"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Zapisz zmiany w tym obszarze roboczym"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Zapisz jako nowy obszar roboczy..."), + ("window.editWorkspaces", "Edit Workspaces...", "Edytuj obszary robocze..."), + ("", "Assign Shortcut to Workspace", "Przypisz skrót do obszaru roboczego"), + ("window.panel", "Align", "Wyrównanie"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pędzle"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Wypełnienie z uwzględnieniem zawartości"), + ("window.panel", "Effects & Presets", "Efekty i ustawienia"), + ("window.panel", "Essential Graphics", "Essential Graphics"), + ("window.panel", "Info", "Informacje"), + ("help.inAppTutorials", "Learn", "Nauka"), + ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolacja maski"), + ("window.panel", "Media Browser", "Przeglądarka multimediów"), + ("window.panel", "Metadata", "Metadane"), + ("window.panel", "Motion Sketch", "Szkic ruchu"), + ("window.panel", "Paint", "Malowanie"), + ("window.panel", "Paragraph", "Akapit"), + ("window.panel", "Preview", "Podgląd"), + ("window.panel", "Progress", "Postęp"), + ("window.panel", "Properties", "Właściwości"), + ("window.panel", "Script Console", "Konsola skryptów"), + ("window.panel", "Smoother", "Wygładzanie"), + ("window.panel", "Tools", "Narzędzia"), + ("window.panel", "Tracker", "Śledzenie"), + ("window.panel", "Wiggler", "Wahanie"), + ("window.panel", "Composition", "Kompozycja"), + ("window.panel", "Flowchart", "Schemat"), + ("window.panel", "Footage", "Materiał"), + ("window.panel", "Layer", "Warstwa"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Kolejka renderowania"), + ("window.panel", "Timeline", "Oś czasu"), + ("window.panel", "Create Nulls From Paths", "Utwórz obiekty zerowe ze ścieżek"), + ("window.panel", "VR Comp Editor", "Edytor kompozycji VR"), + ("", "Help", "Pomoc"), + ("help.docs", "EffectCraft Help...", "Pomoc EffectCraft..."), + ("help.docs", "Scripting Help...", "Pomoc dotycząca skryptów..."), + ("help.docs", "Expression Reference...", "Dokumentacja wyrażeń..."), + ("help.docs", "Effect Reference...", "Dokumentacja efektów..."), + ("anim.browsePresets", "Animation Presets...", "Ustawienia animacji..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Skróty klawiaturowe..."), + ("help.inAppTutorials", "In-App Tutorials...", "Samouczki w aplikacji..."), + ("help.onlineTutorials", "Online Tutorials...", "Samouczki online..."), + ("help.systemReport", "System Compatibility Report...", "Raport zgodności systemu..."), + ("help.enableLogging", "Enable Logging", "Włącz rejestrowanie"), + ("help.revealLogFile", "Reveal Logging File", "Pokaż plik dziennika"), + ("help.discord", "Join the ArtCraft Discord...", "Dołącz do Discorda ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Prześlij opinię..."), + ("help.website", "ArtCraft Website", "Witryna ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Strona główna EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("file.openDemoProject", "Open Demo Project", "Otwórz projekt demonstracyjny"), + ("", "Recent Script Files", "Ostatnie pliki skryptów"), + ("layer.newPrimitive", "3D Primitive...", "Bryła podstawowa 3D..."), +]; + /// Czech (cs): original phrase translations of every fixed menu entry. const CZECH: &[(&str, &str, &str)] = &[ ("", "Front", "Přední strana"), @@ -6561,6 +7245,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), + ("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), @@ -7192,6 +7878,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"), + ("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"), ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), @@ -7512,6 +8200,19 @@ mod tests { println!("total: {total}"); } + /// Prints the translated-row counts per language, the numbers `docs/localization-parity.md` + /// quotes. `cargo xtask i18n-report` runs it with `--nocapture`. + #[test] + fn catalog_row_counts_report() { + println!("Localization catalog rows:"); + for code in CATALOG_CODES { + let menus = menu_catalog(code).map_or(0, |catalog| catalog.len()); + let panels = ui::translated_rows(code); + let effect_names = effects::translated_rows(code); + println!("{code}: menus {menus}, panels {panels}, effect names {effect_names}, total {}", menus + panels + effect_names); + } + } + #[test] fn menu_catalogs_have_no_stale_or_duplicate_rows() { let mut actual = BTreeSet::new(); @@ -7550,6 +8251,43 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + /// Match System in the browser also picks the Russian, French and German catalogs, in the + /// order of the browser's preferences (#1700). + #[test] + fn browser_preferences_choose_russian_french_and_german() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + for (locales, expected) in + [(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")] + { + assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}"); + } + } + + /// The Edit menu's Undo and Redo name the history's action in the interface language, also in + /// the languages added with #1700. + #[test] + fn undo_and_redo_are_localized_for_russian_french_and_german() { + for (language, undo, no_undo, redo, no_redo) in [ + ("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"), + ("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"), + ("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"), + ] { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + let entry_of = |app: &EffectcraftApp, command: &str| { + let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap(); + entry(app, e, e.label.clone()) + }; + assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}"); + assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}"); + app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap(); + let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap(); + assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}"); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}"); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -7579,6 +8317,10 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("pl"), "pl"), + (Some("pl-PL"), "pl"), + (Some("pl_PL.UTF-8"), "pl"), + (Some("PL_pl.UTF-8"), "pl"), (Some("ka"), "ka"), (Some("ka-GE"), "ka"), (Some("ka_GE.UTF-8"), "ka"), @@ -7606,6 +8348,7 @@ mod tests { assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); assert_eq!(czech(&app), system_language() == "cs"); + assert_eq!(polish(&app), system_language() == "pl"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -7632,6 +8375,7 @@ mod tests { ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH), + ("pl", POLISH), ("ka", GEORGIAN), ] { for (command, en, translated) in catalog { @@ -7643,6 +8387,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); @@ -7676,7 +8449,7 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label"); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); } diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 0e767f5ac..bb7e3715a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -87,6 +87,13 @@ pub(crate) fn name(id: &str, english: &str) -> String { catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } +/// The number of effect-name rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, |c| c.rows.len()) +} + /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 061090871..c137204e6 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -22,7 +22,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "pl", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -50,6 +50,7 @@ const ROWS: &[(&str, &[(&str, &str)])] = &[ ("pt-br", PORTUGUESE), ("cs", CZECH), ("es", SPANISH), + ("pl", POLISH), ("ka", GEORGIAN), ("ka", GEORGIAN_PANELS), ]; @@ -64,6 +65,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static PL: OnceLock = OnceLock::new(); static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), @@ -73,6 +75,7 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + "pl" => (&PL, POLISH), // Georgian also reads its own rows for text the other catalogs don't have yet. "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, @@ -92,6 +95,13 @@ pub(super) fn missing(language: &str) -> Vec { rows.chain(literals).filter(|en| !table.is_some_and(|c| c.contains_key(en.as_str())) && seen.insert(en.clone())).collect() } +/// The number of panel/dialog rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, Index::len) +} + /// The string literals `src` passes to `tr(` and `tr_args(`. #[cfg(test)] fn tr_literals(src: &str) -> Vec { @@ -196,6 +206,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -434,6 +445,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -469,6 +481,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセル縦横比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -902,6 +915,310 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Basic", "基本"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -938,6 +1255,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1167,6 +1485,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1202,6 +1521,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1686,6 +2006,327 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Pre-roll", "预卷"), ("Post-roll", "后卷"), ("If caching, play cached frames", "缓存时播放已缓存帧"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1722,6 +2363,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -1951,6 +2593,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -1986,6 +2629,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2416,6 +3060,327 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Pre-roll", "預卷"), ("Post-roll", "後卷"), ("If caching, play cached frames", "快取時播放已快取影格"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2695,6 +3660,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2996,6 +3962,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ // Brazilian Portuguese. const PORTUGUESE: &[(&str, &str)] = &[ + ("Export Scene to Blender...", "Exportar cena para o Blender..."), (" OK ", " OK "), (" Delete ", " Excluir "), (" Open Auto-Save ", " Abrir salvamento automático "), @@ -3230,6 +4197,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3745,6 +4713,753 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("files", "arquivos"), ]; +/// Polish (pl): original translations covering every row in the Chinese panel catalogs. +const POLISH: &[(&str, &str)] = &[ + ("Appearance Settings…", "Ustawienia wyglądu…"), + ("Add to favorites", "Dodaj do ulubionych"), + ("Remove from favorites", "Usuń z ulubionych"), + (" OK ", " OK "), + (" Delete ", " Usuń "), + (" Open Auto-Save ", " Otwórz autozapis "), + ("(missing)", "(brak)"), + ("360 View Orientation", "Orientacja widoku 360"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompozytor grafiki ruchomej i efektów wizualnych, open source i tworzony metodą clean-room: natywny w macOS, Windows i Linuksie oraz w przeglądarce. Część rodziny aplikacji twórczych ArtCraft.", + ), + ("About EffectCraft", "O programie EffectCraft"), + ("About...", "O programie..."), + ("Active", "Aktywny"), + ("Active Camera", "Aktywna kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Dostosuj cechę w każdej klatce"), + ("Adaptive Sample Limit:", "Adaptacyjny limit próbek:"), + ("Add Animator", "Dodaj animator"), + ("Add Comment", "Dodaj komentarz"), + ("Add Formatting", "Dodaj formatowanie"), + ("Add Group", "Dodaj grupę"), + ("Add Mirror", "Dodaj lustro"), + ("Add Selected As", "Dodaj zaznaczone jako"), + ("Add a comment", "Dodaj komentarz"), + ("Add a shape, fill, stroke or path operation", "Dodaj kształt, wypełnienie, obrys lub operację na ścieżce"), + ("Add to Composition", "Dodaj do kompozycji"), + ("Add to Favorites", "Dodaj do ulubionych"), + ("Add:", "Dodaj:"), + ("Adjust Exposure (stops)", "Dostosuj ekspozycję (EV)"), + ("Adjust composition duration to the time span of the selected layers", "Dopasuj czas trwania kompozycji do zakresu czasu zaznaczonych warstw"), + ("Advanced 3D", "Zaawansowane 3D"), + ("Align Layers to:", "Wyrównaj warstwy do:"), + ("All six face cameras turn together; the scene is not changed.", "Wszystkie sześć kamer ścian obraca się razem; scena się nie zmienia."), + ("Anchor:", "Zakotwiczenie:"), + ("Aperture", "Przysłona"), + ("Appearance Mode", "Tryb wyglądu"), + ("Appearance Mode: {}", "Tryb wyglądu: {}"), + ("Apply Dimensions:", "Zastosuj wymiary:"), + ("Apply Motion To:", "Zastosuj ruch do:"), + ("Arrange", "Kolejność"), + ("Assign", "Przypisz"), + ("Auto", "Auto"), + ("Auto-saves", "Autozapisy"), + ("Back", "Tył"), + ("Background Color:", "Kolor tła:"), + ("Blend Mode:", "Tryb mieszania:"), + ("Blending Mode", "Tryb mieszania"), + ("Blur", "Rozmycie"), + ("Blur Level", "Poziom rozmycia"), + ("Boundary", "Granica"), + ("Browser Storage", "Pamięć przeglądarki"), + ("Browse…", "Przeglądaj…"), + ("Brush Dynamics", "Dynamika pędzla"), + ("Caching frames…", "Buforowanie klatek…"), + ("Cancel", "Anuluj"), + ("Casts Shadows", "Rzuca cienie"), + ("Change Renderer…", "Zmień renderer…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Zmiana skrótu tworzy niestandardowe ustawienie; domyślne pozostaje bez zmian.", + ), + ("Channel", "Kanał"), + ("Channel:", "Kanał:"), + ("Chapter:", "Rozdział:"), + ("Choose...", "Wybierz..."), + ("Classic 3D", "Klasyczne 3D"), + ("Clear", "Wyczyść"), + ("Clear list", "Wyczyść listę"), + ("Click a key to see its commands.", "Kliknij klawisz, aby zobaczyć jego polecenia."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Kliknij nad paskiem, aby dodać punkt krycia, pod nim, aby dodać punkt koloru.", + ), + ("Click adds a point (½ height = no change)", "Kliknięcie dodaje punkt (½ wysokości = bez zmian)"), + ("Click adds a point; drag one off to remove", "Kliknięcie dodaje punkt; przeciągnij go poza obszar, aby usunąć"), + ("Click to make this viewer active", "Kliknij, aby uaktywnić tę przeglądarkę"), + ("Click to reveal the expression in the timeline", "Kliknij, aby pokazać wyrażenie na osi czasu"), + ("Click to show the active composition here", "Kliknij, aby pokazać tu aktywną kompozycję"), + ("Click, then click in the Composition panel", "Kliknij, a potem kliknij w panelu Kompozycja"), + ("Clone Options", "Opcje klonowania"), + ("Clone Source Overlay:", "Nakładka źródła klonowania:"), + ("Close", "Zamknij"), + ("Close Panel", "Zamknij panel"), + ("Close tutorial", "Zamknij samouczek"), + ("Color", "Kolor"), + ("Color Depth: {}", "Głębia kolorów: {}"), + ("Color:", "Kolor:"), + ("Colour map", "Mapa kolorów"), + ("Columns", "Kolumny"), + ("Comment:", "Komentarz:"), + ("Community", "Społeczność"), + ("Composition", "Kompozycja"), + ("Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("Composition Name:", "Nazwa kompozycji:"), + ("Composition Settings ▸ 3D Renderer", "Ustawienia kompozycji ▸ Renderer 3D"), + ("Composition Settings…", "Ustawienia kompozycji…"), + ("Composition marker bin: drag a marker to the time ruler", "Pojemnik znaczników kompozycji: przeciągnij znacznik na linijkę czasu"), + ("Cone Angle", "Kąt stożka"), + ("Cone Feather", "Wtapianie stożka"), + ("Constrain Proportions", "Zachowaj proporcje"), + ("Continuous (Lock Outgoing to Incoming)", "Ciągły (zablokuj wyjście do wejścia)"), + ("Cue Point", "Punkt sygnału"), + ("Current Render", "Bieżące renderowanie"), + ("Dark", "Ciemny"), + ("Dark Theme", "Ciemny motyw"), + ("Darker", "Ciemniej"), + ("Default Output:", "Domyślne wyjście:"), + ("Default: {}", "Domyślnie: {}"), + ("Defaults", "Domyślne"), + ("Delete", "Usuń"), + ("Delete Selected Points", "Usuń zaznaczone punkty"), + ("Delete selected project items", "Usuń zaznaczone elementy projektu"), + ("Delete this template", "Usuń ten szablon"), + ("Depth of Field", "Głębia ostrości"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmieść warstwy:"), + ("Don't Recover", "Nie odzyskuj"), + ("Don't Save", "Nie zapisuj"), + ("Double-click a layer to open it in the Layer panel", "Kliknij dwukrotnie warstwę, aby otworzyć ją w panelu Warstwa"), + ("Double-click footage in the Project panel to open it here", "Kliknij dwukrotnie materiał w panelu Projekt, aby otworzyć go tutaj"), + ("Double-click to edit the comment", "Kliknij dwukrotnie, aby edytować komentarz"), + ("Download", "Pobierz"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Przeciągnij tu właściwości z osi czasu\nalbo użyj Animacja > Dodaj właściwość do Essential Graphics.", + ), + ("Drag to change", "Przeciągnij, aby zmienić"), + ("Drag to create a guide", "Przeciągnij, aby utworzyć linię pomocniczą"), + ("Drag to slip the source", "Przeciągnij, aby przesunąć źródło"), + ("Drop Frame", "Z pomijaniem klatek"), + ("Duplicate", "Powiel"), + ("Duration:", "Czas trwania:"), + ("Edit Gradient…", "Edytuj gradient…"), + ("Edit Target: {}", "Cel edycji: {}"), + ("Edit in the Timeline", "Edytuj na osi czasu"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), + ("Effects & Presets options", "Opcje efektów i ustawień"), + ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Pusty folder"), + ("Enable Depth of Field", "Włącz głębię ostrości"), + ("Enable Expression", "Włącz wyrażenie"), + ("Enhance", "Ulepsz"), + ("Essential Properties — {}", "Najważniejsze właściwości — {}"), + ("Event", "Zdarzenie"), + ("Export Template…", "Eksportuj szablon…"), + ("Export...", "Eksportuj..."), + ("Expression Language Menu", "Menu języka wyrażeń"), + ("Expression pick whip: drag onto a property", "Łącznik wyrażenia: przeciągnij na właściwość"), + ("Expression:", "Wyrażenie:"), + ("Eyedropper: click, then click in the Composition panel", "Kroplomierz: kliknij, a potem kliknij w panelu Kompozycja"), + ("Falloff", "Zanikanie"), + ("Falloff Distance", "Odległość zanikania"), + ("Favorites", "Ulubione"), + ("Field Render: {}", "Renderowanie pól: {}"), + ("Figures", "Cyfry"), + ("Fill Target", "Cel wypełnienia"), + ("Finished", "Zakończono"), + ("Flowchart: {}", "Schemat: {}"), + ("Focus Distance", "Odległość ostrości"), + ("Follow Feature Rotation and Scale", "Podążaj za obrotem i skalą cechy"), + ("Footage: {}", "Materiał: {}"), + ("Format: {}", "Format: {}"), + ("Frame Aspect Ratio: {}", "Proporcje klatki: {}"), + ("Frame Blending: {}", "Mieszanie klatek: {}"), + ("Frame Rate:", "Liczba klatek na sekundę:"), + ("Frame Render Time {}ms", "Czas renderowania klatki {}ms"), + ("Frame Target:", "Cel klatki:"), + ("Freeze", "Zamroź"), + ("Grab bag", "Różności"), + ("Gradient Editor", "Edytor gradientu"), + ("Grid", "Siatka"), + ("Guide Layers: {}", "Warstwy pomocnicze: {}"), + ("Guides", "Linie pomocnicze"), + ("Height:", "Wysokość:"), + ("Hide This", "Ukryj to"), + ("Histogram", "Histogram"), + ("Hold In Place", "Zatrzymaj w miejscu"), + ("Home", "Start"), + ("If Confidence is Below", "Jeśli pewność jest poniżej"), + ("Import", "Importuj"), + ("Import and Add to Composition", "Importuj i dodaj do kompozycji"), + ("Import...", "Importuj..."), + ("Imported media", "Zaimportowane multimedia"), + ("Influence:", "Wpływ:"), + ("Install from File…", "Zainstaluj z pliku…"), + ("Intensity", "Natężenie"), + ("Inverted", "Odwrócone"), + ("Join the ArtCraft community on Discord", "Dołącz do społeczności ArtCraft na Discordzie"), + ("Key: {}", "Klawisz: {}"), + ("LEARN", "NAUKA"), + ("Label", "Etykieta"), + ("Label Colors and Names:", "Kolory i nazwy etykiet:"), + ("Label:", "Etykieta:"), + ("Latest auto-save: {}", "Ostatni autozapis: {}"), + ("Layer", "Warstwa"), + ("Layer controls", "Elementy sterujące warstwy"), + ("Layer:", "Warstwa:"), + ("Layer: {}", "Warstwa: {}"), + ("Learn", "Nauka"), + ("Leave all attributes in \"{}\"", "Zachowaj wszystkie atrybuty w „{}”"), + ("Licence: {}", "Licencja: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Jasny"), + ("Light Theme", "Jasny motyw"), + ("Light Type", "Typ światła"), + ("Lighter", "Jaśniej"), + ("Link Selected Property", "Połącz zaznaczoną właściwość"), + ("Location:", "Lokalizacja:"), + ("Lock Aspect Ratio to {}", "Zablokuj proporcje na {}"), + ("Lock to Zoom", "Zablokuj do powiększenia"), + ("Locked: {} — {}", "Zablokowano: {} — {}"), + ("Log:", "Dziennik:"), + ("Loop (Trace Path)", "Pętla (śledź ścieżkę)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Czcionki: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu współczynnika powiększenia"), + ("Make Comp Size", "Ustaw rozmiar kompozycji"), + ("Markers", "Znaczniki"), + ("Masks", "Maski"), + ("Maximize Panel", "Maksymalizuj panel"), + ("Media Replacement", "Zamiana multimediów"), + ("Mesh:", "Siatka:"), + ("Diameter:", "Średnica:"), + ("Method Used: {} Average Error: {} pixels", "Użyta metoda: {} Średni błąd: {} px"), + ("Models folder: {}", "Folder modeli: {}"), + ("Modifiers:", "Modyfikatory:"), + ("More", "Więcej"), + ("More ArtCraft apps", "Więcej aplikacji ArtCraft"), + ("More tutorials", "Więcej samouczków"), + ("Motion Blur", "Rozmycie ruchu"), + ("Motion Blur: {}", "Rozmycie ruchu: {}"), + ("Motion Sketch: drag to record", "Szkic ruchu: przeciągnij, aby nagrać"), + ("Move Down", "Przesuń w dół"), + ("Move Up", "Przesuń w górę"), + ("Move to Project Root", "Przenieś do katalogu głównego projektu"), + ("Name", "Nazwa"), + ("Name:", "Nazwa:"), + ("Navigation", "Nawigacja"), + ("New", "Nowy"), + ("New Comp from Selection", "Nowa kompozycja z zaznaczenia"), + ("Replace with Precomposition", "Zastąp kompozycją wstępną"), + ("New Duration:", "Nowy czas trwania:"), + ("New composition name:", "Nazwa nowej kompozycji:"), + ("New from Render Queue item:", "Nowy z elementu kolejki renderowania:"), + ("New from Template", "Nowy z szablonu"), + ("Next", "Dalej"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Dobra robota. Wybierz kolejny samouczek na karcie Nauka ekranu startowego."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Brak środowisk VR. Utwórz je poleceniem Kompozycja ▸ VR ▸ Utwórz środowisko VR…", + ), + ("No audio layer selected", "Nie zaznaczono warstwy audio"), + ("No background jobs running", "Brak zadań w tle"), + ("No composition markers", "Brak znaczników kompozycji"), + ("No contributor data was built into this copy.", "Ta kopia nie zawiera danych o współtwórcach."), + ("No device list from this host; the system default output is used.", "Ten system nie podaje listy urządzeń; używane jest domyślne wyjście systemowe."), + ("No layer selected", "Nie zaznaczono warstwy"), + ("No matching effects or presets", "Brak pasujących efektów lub ustawień"), + ("No matching fonts", "Brak pasujących czcionek"), + ("Variable fonts only", "Tylko czcionki zmienne"), + ("No model credits were built into this copy.", "Ta kopia nie zawiera informacji o modelach."), + ("No nested compositions", "Brak zagnieżdżonych kompozycji"), + ("No shortcut", "Brak skrótu"), + ("No unlocked layer selected", "Nie zaznaczono odblokowanej warstwy"), + ("No video", "Brak wideo"), + ("None", "Brak"), + ("Not applied: tick it in the list to add it.", "Niezastosowany: zaznacz go na liście, aby go dodać."), + ("Not assigned", "Nieprzypisany"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Uwaga: cienie rzucają tylko warstwy z włączoną opcją Rzuca cienie."), + ("Notify", "Powiadom"), + ("OK", "OK"), + ("Offset:", "Przesunięcie:"), + ("On ({}) stop:", "Punkt na ({}):"), + ("One-Node Camera", "Kamera jednowęzłowa"), + ("Opacity:", "Krycie:"), + ("Open Composition", "Otwórz kompozycję"), + ("used once", "użyty raz"), + ("used {} times", "użycia: {}"), + ("Compositions that use this item", "Kompozycje, które używają tego elementu"), + ("Open Last Saved Project", "Otwórz ostatnio zapisany projekt"), + ("Open New Composition", "Otwórz nową kompozycję"), + ("Open Output", "Otwórz wynik"), + ("Open a composition to build Essential Graphics", "Otwórz kompozycję, aby zbudować Essential Graphics"), + ("Open a composition to see its scopes", "Otwórz kompozycję, aby zobaczyć jej zakresy"), + ("Open a composition to track motion.", "Otwórz kompozycję, aby śledzić ruch."), + ("Opus Tuning: {}", "Strojenie Opus: {}"), + ("Original Duration:", "Oryginalny czas trwania:"), + ("Output Cycle: {}", "Cykl wyjścia: {}"), + ("Output To:", "Wyjście do:"), + ("Overlay", "Nakładka"), + ("Parameter Name:", "Nazwa parametru:"), + ("Parent pick whip: drag onto a layer", "Łącznik rodzica: przeciągnij na warstwę"), + ("Pixel Aspect Ratio:", "Proporcje piksela:"), + ("Playing on Video Preview", "Odtwarzanie w podglądzie wideo"), + ("Post-Render Action: {}", "Akcja po renderowaniu: {}"), + ("Preserve frame rate when nested or in render queue", "Zachowaj liczbę klatek po zagnieżdżeniu lub w kolejce renderowania"), + ("Preserve resolution when nested", "Zachowaj rozdzielczość po zagnieżdżeniu"), + ("Preset", "Ustawienie"), + ("Preset:", "Ustawienie:"), + ("Presets", "Ustawienia"), + ("Previous", "Wstecz"), + ("Primary:", "Główny:"), + ("Process Before Match", "Przetwarzaj przed dopasowaniem"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Głębia kolorów projektu: kliknij, aby przełączać 8/16/32 bpc, Alt-klik otwiera Ustawienia projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Łącznik właściwości: przeciągnij na właściwość"), + ("Protected Region", "Obszar chroniony"), + ("Proxy Use: {}", "Użycie proxy: {}"), + ("Push All to Comp", "Wypchnij wszystko do kompozycji"), + ("RGB", "RGB"), + ("Radius", "Promień"), + ("Rate Control: {}", "Kontrola przepływności: {}"), + ("Recent", "Ostatnie"), + ("Recording", "Nagrywanie"), + ("Remove", "Usuń"), + ("Rename", "Zmień nazwę"), + ("Render: {} ms • UI {} fps{}", "Renderowanie: {} ms • UI {} fps{}"), + ("Renderer:", "Renderer:"), + ("Request Persistent Storage", "Poproś o trwałą pamięć"), + ("Reset", "Resetuj"), + ("Reset Labels", "Resetuj etykiety"), + ("Reset Preset", "Resetuj ustawienie"), + ("Reset to Default", "Przywróć domyślne"), + ("Reset to Saved Layout", "Przywróć zapisany układ"), + ("Resolution/Down Sample Factor Popup", "Menu rozdzielczości/współczynnika próbkowania"), + ("Restore Panel Size", "Przywróć rozmiar panelu"), + ("Reverse the order", "Odwróć kolejność"), + ("Revert All", "Przywróć wszystko"), + ("Roman Hanging Punctuation", "Interpunkcja wisząca (łacińska)"), + ("Roving:", "Wędrujące:"), + ("Ruler zero point (drag to move, double-click to reset)", "Punkt zerowy linijki (przeciągnij, aby przesunąć, kliknij dwukrotnie, aby zresetować)"), + ("Rulers", "Linijki"), + ("Run", "Uruchom"), + ("Sample Format: {}", "Format próbek: {}"), + ("Samples Per Frame:", "Próbek na klatkę:"), + ("Save from #{}", "Zapisz od #{}"), + ("Saved {}", "Zapisano {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skrypt… (⌘/Ctrl+Enter uruchamia)"), + ("Search commands or shortcuts", "Szukaj poleceń lub skrótów"), + ("Select", "Zaznacz"), + ("Select a command to change its shortcut.", "Zaznacz polecenie, aby zmienić jego skrót."), + ("Select a layer to see its effects", "Zaznacz warstwę, aby zobaczyć jej efekty"), + ("Select a layer to see its properties", "Zaznacz warstwę, aby zobaczyć jej właściwości"), + ("({}) Selected Objects", "Zaznaczone obiekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Zaznacz warstwę z maską lub ścieżką kształtu (albo właściwość ścieżki) lub pinezki Puppet.", + ), + ("Select a text layer", "Zaznacz warstwę tekstową"), + ("Select an item in the Project panel", "Zaznacz element w panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Zaznacz animowane właściwości, aby pokazać je w edytorze wykresów"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Zaznacz pinezkę prowadzącą, a potem Shift-kliknij pinezki, które mają za nią podążać (włosy, tkanina, ogony)", + ), + ("Set Ground Plane and Origin", "Ustaw płaszczyznę podłoża i początek układu"), + ("Set Paint Colors to Black and White (D)", "Ustaw kolory malowania na czarny i biały (D)"), + ("Settings", "Ustawienia"), + ("Settings Name:", "Nazwa ustawień:"), + ("Shadow Darkness", "Ciemność cienia"), + ("Shadow Diffusion", "Rozproszenie cienia"), + ("Shadows", "Cienie"), + ("Shape…", "Kształt…"), + ("Show", "Pokaż"), + ("Show Layer Name", "Pokaż nazwę warstwy"), + ("Show Source Name", "Pokaż nazwę źródła"), + ("Show me", "Pokaż mi"), + ("Show:", "Pokaż:"), + ("Shutter Angle:", "Kąt migawki:"), + ("Shutter Phase:", "Faza migawki:"), + ("Size", "Rozmiar"), + ("Skip", "Pomiń"), + ("Snapping", "Przyciąganie"), + ("Snapping options", "Opcje przyciągania"), + ("Snapshot", "Migawka"), + ("Solo Supported Properties", "Solo dla obsługiwanych właściwości"), + ("Solo Switches: {}", "Przełączniki solo: {}"), + ("Sort", "Sortuj"), + ("Spacing:", "Odstęp:"), + ("Spatial Interpolation:", "Interpolacja przestrzenna:"), + ("Speed:", "Szybkość:"), + ("Start Timecode:", "Początkowy kod czasowy:"), + ("Start: {} Duration: {}", "Początek: {} Czas trwania: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zatrzymaj"), + ("Stop {} at {}°", "Punkt {} na {}°"), + ("Stored in: {}", "Przechowywane w: {}"), + ("Stretch", "Rozciągnij"), + ("Stretch Factor:", "Współczynnik rozciągnięcia:"), + ("Stylistic Sets", "Zestawy stylistyczne"), + ("Subpixel Positioning", "Pozycjonowanie podpikselowe"), + ("Swap Colors", "Zamień kolory"), + ("Swap Paint Colors (X)", "Zamień kolory malowania (X)"), + ("Sync with System", "Synchronizuj z systemem"), + ("System Default", "Domyślne systemowe"), + ("Table", "Tabela"), + ("Template name", "Nazwa szablonu"), + ("Template: {}\n{}", "Szablon: {}\n{}"), + ("Temporal Interpolation:", "Interpolacja czasowa:"), + ("The Media Browser is not available here (use File ▸ Import)", "Przeglądarka multimediów nie jest tu dostępna (użyj Plik ▸ Importuj)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Warstwa OpenEXR pokazana w kanałach czerwonym, zielonym, niebieskim i alfa"), + ("The render queue is empty.", "Kolejka renderowania jest pusta."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Te sąsiednie klatki kluczowe nie mogą być interpolowane, bo mają różną liczbę punktów.", + ), + ("This ScriptUI panel has closed", "Ten panel ScriptUI został zamknięty"), + ("This change will create a new solid for this layer.", "Ta zmiana utworzy nową planszę dla tej warstwy."), + ("Time", "Czas"), + ("Time Stretch…", "Rozciągnięcie czasu…"), + ("Time:", "Czas:"), + ("Time: {}", "Czas: {}"), + ("Title/Action Safe", "Obszar bezpieczny tytułów/akcji"), + ("Title/action safe", "Obszar bezpieczny tytułów/akcji"), + ("Toggle Switches / Modes", "Przełącz przełączniki / tryby"), + ("Track Name:", "Nazwa ścieżki:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Wytrenowane modele są dostępne w aplikacji desktopowej; przeglądarka używa klasycznego silnika.", + ), + ("Transform", "Przekształć"), + ("Transparency grid", "Siatka przezroczystości"), + ("Tutorial complete", "Samouczek ukończony"), + ("Two-Node Camera", "Kamera dwuwęzłowa"), + ("Type", "Typ"), + ("Type a command or effect…", "Wpisz polecenie lub efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odepnij panel"), + ("Move Panel to New Window", "Przenieś panel do nowego okna"), + ("Move Panel to Main Window", "Przenieś panel do głównego okna"), + ("Unfreeze", "Odmroź"), + ("Unlink {}", "Odłącz {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Użyj tej opcji, aby umieścić zaznaczone warstwy razem w nowej kompozycji pośredniej.", + ), + ("VR Environments", "Środowiska VR"), + ("Value:", "Wartość:"), + ("Variable Font Axes", "Osie czcionki zmiennej"), + ("Video Codec: {}", "Kodek wideo: {}"), + ("View:", "Widok:"), + ("Welcome to EffectCraft", "Witaj w EffectCraft"), + ("Width:", "Szerokość:"), + ("You can undo this.", "Można to cofnąć."), + ("You can't undo this.", "Tego nie można cofnąć."), + ("Your changes will be lost if you don't save them.", "Jeśli nie zapiszesz zmian, zostaną utracone."), + ("Zoom", "Powiększenie"), + ("built-in", "wbudowany"), + ("frames per second", "klatek na sekundę"), + ("template name", "nazwa szablonu"), + ("{} contributors · {} commits", "Współtwórcy: {} · commity: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {} MB na klatkę 8 bpc"), + ("{} — coming soon", "{} — wkrótce"), + ("{} — double-click to open, {}-click to reveal", "{} — kliknij dwukrotnie, aby otworzyć, {}-klik, aby pokazać w folderze"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-przeciągnij pinezkę, aby nagrać jej ruch"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} jest używany przez:"), + ("3D Reference Axes", "Osie odniesienia 3D"), + ("Affect all layers that use this solid", "Dotyczy wszystkich warstw używających tej planszy"), + ("Choose grid and guide options", "Wybierz opcje siatki i linii pomocniczych"), + ("Colorize", "Koloryzuj"), + ("Custom", "Niestandardowe"), + ("Custom...", "Niestandardowe..."), + ("Fit", "Dopasuj"), + ("Fit up to 100%", "Dopasuj do 100%"), + ("Full", "Pełna"), + ("Half", "Połowa"), + ("Only this layer uses this solid.", "Tylko ta warstwa używa tej planszy."), + ("Proportional Grid", "Siatka proporcjonalna"), + ("Quarter", "Jedna czwarta"), + ("Region of Interest", "Obszar zainteresowania"), + ("Reset Exposure", "Resetuj ekspozycję"), + ("Show Channel and Color Management Settings", "Pokaż ustawienia kanałów i zarządzania kolorem"), + ("Show Snapshot (hold)", "Pokaż migawkę (przytrzymaj)"), + ("Take Snapshot", "Zrób migawkę"), + ("Third", "Jedna trzecia"), + ("Toggle Mask and Shape Path Visibility", "Przełącz widoczność ścieżek masek i kształtów"), + ("Toggle Transparency Grid", "Przełącz siatkę przezroczystości"), + ("16 bits per channel", "16 bitów na kanał"), + ("32 bits per channel (float)", "32 bity na kanał (zmiennoprzecinkowe)"), + ("8 bits per channel", "8 bitów na kanał"), + ("Adaptive Resolution", "Rozdzielczość adaptacyjna"), + ("Adobe-style built-in", "Wbudowany w stylu Adobe"), + ("Blue", "Niebieski"), + ("Current Settings", "Bieżące ustawienia"), + ("Disable Per-character 3D", "Wyłącz 3D dla każdego znaku"), + ("Draft", "Robocza"), + ("Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("Fast Draft", "Szybka robocza"), + ("Fast Previews (Draft 3D, Faster Effects)", "Szybkie podglądy (robocze 3D, szybsze efekty)"), + ("Green", "Zielony"), + ("Import Image Sequence", "Importuj sekwencję obrazów"), + ("Import PDF / Illustrator File", "Importuj plik PDF / Illustrator"), + ("Import Photoshop File", "Importuj plik Photoshopa"), + ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), + ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), + ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), + ("Mercury Software Only", "Tylko programowy Mercury"), + ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), + ("Off (Final Quality)", "Wył. (jakość końcowa)"), + ("OpenGL (compatibility)", "OpenGL (zgodność)"), + ("Opus Bitrate: {} kbps", "Przepływność Opus: {} kb/s"), + ("RGB Straight", "RGB proste"), + ("Red", "Czerwony"), + ("Tone map", "Mapowanie tonów"), + ("Use In-Window Menu Bar on macOS", "Używaj paska menu w oknie w macOS"), + ("Wireframe", "Szkielet"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (domyślny)"), + ("New Project", "Nowy projekt"), + ("Open Project…", "Otwórz projekt…"), + ("Templates", "Szablony"), + ("EffectCraft home page", "Strona główna EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("Motion graphics and visual effects · version {}", "Grafika ruchoma i efekty wizualne · wersja {}"), + ("New Composition", "Nowa kompozycja"), + ("Open Demo Project", "Otwórz projekt demonstracyjny"), + ("Import Footage…", "Importuj materiał…"), + ("Filter recent files", "Filtruj ostatnie pliki"), + ("NAME", "NAZWA"), + ("OPENED", "OTWARTY"), + ("SIZE", "ROZMIAR"), + ("KIND", "RODZAJ"), + ("Your recent files will show here", "Tu pojawią się ostatnie pliki"), + ("Create a new project, or open the demo project to look around.", "Utwórz nowy projekt albo otwórz projekt demonstracyjny, aby się rozejrzeć."), + ("No recent files match the filter", "Żaden ostatni plik nie pasuje do filtra"), + ("Try another name or folder.", "Spróbuj innej nazwy lub folderu."), + ("Just now", "Przed chwilą"), + ("Yesterday", "Wczoraj"), + ("{} minute ago", "{} minutę temu"), + ("{} minutes ago", "{} min temu"), + ("{} hour ago", "{} godzinę temu"), + ("{} hours ago", "{} godz. temu"), + ("{} day ago", "{} dzień temu"), + ("{} days ago", "{} dni temu"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animacja Lottie"), + ("Project", "Projekt"), + ("General", "Ogólne"), + ("Language", "Język"), + ("Levels of Undo", "Poziomy cofania"), + ("Path Point and Handle Size", "Rozmiar punktów i uchwytów ścieżki"), + ("Recent Projects Shown", "Liczba wyświetlanych ostatnich projektów"), + ("Options", "Opcje"), + ("Show Tool Tips", "Pokazuj podpowiedzi"), + ("Create Layers at Composition Start Time", "Twórz warstwy w czasie początku kompozycji"), + ("Switches Affect Nested Comps", "Przełączniki wpływają na zagnieżdżone kompozycje"), + ("Default Spatial Interpolation to Linear", "Domyślna interpolacja przestrzenna liniowa"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Zachowaj stałą liczbę wierzchołków i punktów wtapiania podczas edycji masek"), + ("Synchronize Time of All Related Items", "Synchronizuj czas wszystkich powiązanych elementów"), + ("Expression Pick Whip Writes Compact English", "Łącznik wyrażeń zapisuje zwięzły angielski"), + ("Create Split Layers Above Original Layer", "Twórz podzielone warstwy nad oryginalną warstwą"), + ("Use System Color Picker", "Używaj systemowego próbnika kolorów"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft zawsze używa własnego próbnika kolorów (takiego samego na każdej platformie i w przeglądarce).", + ), + ("Startup & Repair", "Uruchamianie i naprawa"), + ("Show Home Screen When Launching", "Pokazuj ekran startowy przy uruchamianiu"), + ("Show Home Screen When Opening a Project", "Pokazuj ekran startowy przy otwieraniu projektu"), + ("Offer to Open the Latest Auto-Save After a Crash", "Proponuj otwarcie ostatniego autozapisu po awarii"), + ("Window Graphics", "Grafika okna"), + ("Automatic", "Automatycznie"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Grafika okna obowiązuje od następnego uruchomienia. Gdy sterownik graficzny zatrzyma EffectCraft przy otwieraniu okna, następne uruchomienie przełączy się na OpenGL.", + ), + ("Repair", "Naprawa"), + ("Reset Settings", "Resetuj ustawienia"), + ("Reset Keyboard Shortcuts", "Resetuj skróty klawiaturowe"), + ("Empty Disk Cache", "Opróżnij pamięć dyskową"), + ("New Project Loads Template", "Nowy projekt wczytuje szablon"), + ("Template Project", "Projekt szablonu"), + ("Auto-Save", "Autozapis"), + ("Automatically Save Projects", "Automatycznie zapisuj projekty"), + ("Save Every", "Zapisuj co"), + ("Minutes", "min"), + ("Maximum Project Versions", "Maksymalna liczba wersji projektu"), + ("Auto-Save Location", "Lokalizacja autozapisu"), + ("Next to Project", "Obok projektu"), + ("Custom Location", "Lokalizacja niestandardowa"), + ("Save When Starting Render Queue", "Zapisuj przy uruchamianiu kolejki renderowania"), + ("Save Now", "Zapisz teraz"), + ("Motion Path", "Ścieżka ruchu"), + ("All Keyframes", "Wszystkie klatki kluczowe"), + ("No Motion Path", "Bez ścieżki ruchu"), + ("No More Than (Seconds)", "Nie więcej niż (sekundy)"), + ("No More Than (Keyframes)", "Nie więcej niż (klatki kluczowe)"), + ("Seconds", "s"), + ("Keyframes", "Klatki kluczowe"), + ("Display", "Wyświetlanie"), + ("Show Rendering Progress in Info Panel and Flowchart", "Pokazuj postęp renderowania w panelu Informacje i schemacie"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Akceleracja sprzętowa paneli Kompozycja, Warstwa i Materiał"), + ("Panels are always drawn by the GPU (wgpu).", "Panele są zawsze rysowane przez GPU (wgpu)."), + ("Previews", "Podglądy"), + ("Adaptive Resolution Limit", "Limit rozdzielczości adaptacyjnej"), + ("Show Internal Wireframes", "Pokazuj wewnętrzne szkielety"), + ("Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("Viewer Zoom Quality", "Jakość powiększenia przeglądarki"), + ("Faster", "Szybciej"), + ("More Accurate", "Dokładniej"), + ("Display Color Space", "Przestrzeń kolorów wyświetlacza"), + ("GPU Information...", "Informacje o GPU..."), + ("Appearance", "Wygląd"), + ("Brightness", "Jasność"), + ("UI Scale", "Skala interfejsu"), + ("Labels and Colors", "Etykiety i kolory"), + ("Use Label Color for Layer Handles and Paths", "Używaj koloru etykiety dla uchwytów i ścieżek warstw"), + ("Use Label Color for Related Tabs", "Używaj koloru etykiety dla powiązanych kart"), + ("Cycle Mask Colors", "Zmieniaj kolejno kolory masek"), + ("Use Gradients", "Używaj gradientów"), + ("Menu Bar", "Pasek menu"), + ("Grids & Guides", "Siatki i linie pomocnicze"), + ("Gridline Every", "Linia siatki co"), + ("Subdivisions", "Podpodziały"), + ("Style", "Styl"), + ("Lines", "Linie"), + ("Dashed Lines", "Linie przerywane"), + ("Dots", "Kropki"), + ("Horizontal", "Poziomo"), + ("Vertical", "Pionowo"), + ("Safe Margins", "Marginesy bezpieczne"), + ("Action-safe", "Bezpieczny dla akcji"), + ("Title-safe", "Bezpieczny dla tytułów"), + ("Labels", "Etykiety"), + ("Text Engine", "Silnik tekstu"), + ("Latin", "Łaciński"), + ("South Asian and Middle Eastern", "Południowoazjatycki i bliskowschodni"), + ("Show Font Preview", "Pokazuj podgląd czcionki"), + ("Number of Recent Fonts to Display", "Liczba wyświetlanych ostatnich czcionek"), + ("Show Font Names in English", "Pokazuj nazwy czcionek po angielsku"), + ("Still Footage", "Materiał nieruchomy"), + ("Length of Composition", "Długość kompozycji"), + ("Duration (Seconds)", "Czas trwania (sekundy)"), + ("Still Duration", "Czas trwania obrazu"), + ("Sequence Footage", "Materiał sekwencyjny"), + ("Report Missing Frames", "Zgłaszaj brakujące klatki"), + ("Automatically Reload Footage", "Automatycznie wczytuj ponownie materiał"), + ("Off", "Wył."), + ("Non-Sequence Footage", "Materiał niesekwencyjny"), + ("All Footage", "Wszystkie materiały"), + ("Ask User", "Pytaj użytkownika"), + ("Guess", "Zgadnij"), + ("Ignore", "Ignoruj"), + ("Straight", "Prosta"), + ("Premultiplied", "Wstępnie mnożona"), + ("Default Drag Import As", "Domyślny import przeciągnięciem jako"), + ("Footage", "Materiał"), + ("Composition - Retain Layer Sizes", "Kompozycja – zachowaj rozmiary warstw"), + ("Export", "Eksport"), + ("Default Output Folder", "Domyślny folder wyjściowy"), + ("Segment Sequences", "Dziel sekwencje na segmenty"), + ("Files per Segment", "Pliki na segment"), + ("files", "pliki"), + ("Segment Movie Files", "Dziel pliki filmowe na segmenty"), + ("Segment Size", "Rozmiar segmentu"), + ("Append Bit Depth to File Name", "Dodawaj głębię bitową do nazwy pliku"), + ("Audio", "Audio"), + ("Audio Hardware", "Sprzęt audio"), + ("Preview Sample Rate", "Częstotliwość próbkowania podglądu"), + ("Audio Output Mapping", "Mapowanie wyjść audio"), + ("Left", "Lewy"), + ("Right", "Prawy"), + ("channel", "kanał"), + ("Disk", "Dysk"), + ("Disk Cache", "Pamięć dyskowa"), + ("Enable Disk Cache", "Włącz pamięć dyskową"), + ("Maximum Disk Cache Size", "Maksymalny rozmiar pamięci dyskowej"), + ("Disk Cache Folder", "Folder pamięci dyskowej"), + ("Media Cache", "Pamięć multimediów"), + ("Database and Cache Folder", "Folder bazy danych i pamięci"), + ("Conformed Audio Folder", "Folder dopasowanego audio"), + ("Memory & CPU", "Pamięć i procesor"), + ("RAM Reserved for Other Applications", "Pamięć RAM zarezerwowana dla innych aplikacji"), + ("Cache Budgets", "Budżety pamięci podręcznej"), + ("Layer Cache", "Pamięć warstw"), + ("Footage Frame Cache", "Pamięć klatek materiałów"), + ("Preview (RAM) Cache", "Pamięć podglądu (RAM)"), + ("Reduce Cache Size When System Is Low on Memory", "Zmniejsz pamięć podręczną, gdy w systemie brakuje pamięci"), + ("Video", "Wideo"), + ("Enable Video Preview Output", "Włącz wyjście podglądu wideo"), + ("Video Device", "Urządzenie wideo"), + ("Floating Window", "Okno pływające"), + ("Full Screen", "Pełny ekran"), + ("Video Output During Playback", "Wyjście wideo podczas odtwarzania"), + ("Mirror on Computer Monitor", "Powielaj na monitorze komputera"), + ("Disable Video Output When in Background", "Wyłącz wyjście wideo w tle"), + ("Default 3D Renderer", "Domyślny renderer 3D"), + ("Show 3D Reference Axes", "Pokazuj osie odniesienia 3D"), + ("Extended Viewer", "Rozszerzona przeglądarka"), + ("Realtime Shadows in Draft", "Cienie w czasie rzeczywistym w trybie roboczym"), + ("Scripting & Expressions", "Skrypty i wyrażenia"), + ("Application Scripting", "Skrypty aplikacji"), + ("Allow Scripts to Write Files and Access Network", "Zezwalaj skryptom na zapis plików i dostęp do sieci"), + ("Warn User When Executing Files", "Ostrzegaj przy uruchamianiu plików"), + ("Enable JavaScript Debugger", "Włącz debuger JavaScript"), + ("Expressions Editor", "Edytor wyrażeń"), + ("Font Size", "Rozmiar czcionki"), + ("Syntax Highlighting", "Podświetlanie składni"), + ("Line Numbers", "Numery wierszy"), + ("Auto-complete", "Autouzupełnianie"), + ("Bracket Matching", "Dopasowywanie nawiasów"), + ("Word Wrap", "Zawijanie wierszy"), + ("Show Expression Error Banner", "Pokazuj pasek błędów wyrażeń"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "Wyrażenia używają silnika JavaScript. Błędy skryptów podają plik i wiersz w konsoli skryptów; nie ma debugera krokowego.", + ), + ("Roto Brush", "Pędzel Roto"), + ("Segmentation Model", "Model segmentacji"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Pędzel Roto 2.0 i 3.0 używa wybranego tu modelu (wersja 1.0 zawsze używa klasycznego silnika). Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Face Tracking", "Śledzenie twarzy"), + ("Face Model", "Model twarzy"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Śledzenie twarzy w panelu Śledzenie (tylko kontur i szczegółowe cechy) używa wybranego tu modelu. Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Match System", "Zgodnie z systemem"), + ("Default", "Domyślny"), + ("Stored, but not used by EffectCraft yet", "Zapisane, ale jeszcze nieużywane przez EffectCraft"), + ("Total Offset", "Całkowite przesunięcie"), + ("Per Layer", "Na warstwę"), + ("Track", "Ścieżka"), + ("Copy", "Kopiuj"), + ("Paste", "Wklej"), + ("Edit Value…", "Edytuj wartość…"), + ("Remove Expression", "Usuń wyrażenie"), + ("Effects", "Efekty"), + ("Copy Source Path", "Kopiuj ścieżkę źródła"), + ("Resolution:", "Rozdzielczość:"), + ("Guide Layer", "Warstwa pomocnicza"), + ("Layer Styles", "Style warstwy"), +]; + /// Czech (cs): original translations covering every row in the Chinese panel catalogs. const CZECH: &[(&str, &str)] = &[ (" OK ", " OK "), @@ -3991,6 +5706,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -4575,6 +6291,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), @@ -5898,6 +7615,8 @@ mod tests { ("engine settings choices", include_str!("../../../engine/src/settings/prefs/choices.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/cmd/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; fn catalogs() -> Vec<(&'static str, &'static str)> { @@ -6017,10 +7736,321 @@ mod tests { assert_eq!(tr_args("Proxy Use: {}", &[]), "Proxy Use: "); } + /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a + /// row (a layer or file name) stays verbatim. + #[test] + fn japanese_rows_translate_the_panels() { + set_current("ja"); + assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); + set_current("en"); + } + /// Every row is asked for by a converted panel: a leftover row is stale, because the panel's /// English source changed or went away. A string a helper draws for the panel (a toolbar /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English /// source only appears as a literal there. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. A call split over + /// several lines is still one argument list, so this walks past newlines until the + /// parenthesis closes. It understands the two string forms a label can be written in - a + /// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis + /// inside either is not mistaken for code. + fn args_of(s: &str) -> Vec<&str> { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + } + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len()); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (i, c) = cs[k]; + match mode { + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + mode = Mode::Code; + k += n; + } + } + } + Mode::Str(escaped) => { + mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) }; + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + if start.is_none() { + start = Some(i); + } + } + 'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + mode = Mode::Raw(n); + start = Some(i); + k += n + 1; + } + } + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + end = i; + break; + } + depth -= 1; + } + ',' if depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + }, + } + k += 1; + } + if let Some(st) = start { + out.push(s[st..end].trim()); + } + out + } + + /// The text a literal argument draws: a plain string with its escapes undone, or a raw + /// string with none. `None` when the argument is not one literal (a concatenation, a call). + fn literal_text(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('r') { + let hashes = rest.chars().take_while(|c| *c == '#').count(); + // An identifier that starts with `r` (`result`) is not a raw string: the quote has + // to follow the `#`s. + if rest.chars().nth(hashes) != Some('"') { + return None; + } + let close = format!("\"{}", "#".repeat(hashes)); + return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string); + } + let body = arg.strip_prefix('"')?.strip_suffix('"')?; + // Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is + // one character (`»`), not six. + let mut out = String::new(); + let mut it = body.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + if c == '"' { + return None; // two literals side by side + } + out.push(c); + continue; + } + match it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'x' => { + let hex: String = it.by_ref().take(2).collect(); + let byte = u8::from_str_radix(&hex, 16).ok()?; + out.push(char::from(byte)); + } + 'u' => { + if it.next() != Some('{') { + return None; + } + let hex: String = it.by_ref().take_while(|c| *c != '}').collect(); + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => { + out.push('\\'); + out.push(other); + } + } + } + Some(out) + } + + /// `source` with comments blanked and the *contents* of every string literal blanked too, + /// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a + /// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not + /// mistaken for a call; the argument list is then read from the original at that offset. + fn code_only(source: &str) -> String { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + Comment, + } + let cs: Vec<(usize, char)> = source.char_indices().collect(); + // Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so + // every offset found here indexes the original source as well. + let mut out: Vec = Vec::with_capacity(source.len()); + let keep = |out: &mut Vec, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); + let blank = |out: &mut Vec, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8())); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (_, c) = cs[k]; + match mode { + Mode::Comment => { + if c == '\n' { + mode = Mode::Code; + keep(&mut out, c); + } else { + blank(&mut out, c); + } + } + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + mode = Mode::Code; + k += n; + } else { + blank(&mut out, c); + } + } else { + blank(&mut out, c); + } + } + Mode::Str(escaped) => { + if c == '"' && !escaped { + mode = Mode::Code; + keep(&mut out, c); + } else { + mode = Mode::Str(c == '\\' && !escaped); + blank(&mut out, c); + } + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + keep(&mut out, c); + } + '/' if out.last() == Some(&b'/') => { + // The first `/` stays; this one becomes a space. Both are one byte, so + // the mask keeps its length and every offset still indexes the source. + blank(&mut out, c); + mode = Mode::Comment; + } + 'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + keep(&mut out, '"'); + mode = Mode::Raw(n); + k += n + 1; + } else { + keep(&mut out, c); + } + } + _ => keep(&mut out, c), + }, + } + k += 1; + } + debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset"); + String::from_utf8(out).expect("mask keeps UTF-8 boundaries") + } + + for (path, source) in CONVERTED { + // The test module draws nothing; it holds sample strings. A test-only helper above it + // (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range, + // because production code follows it. + let cut = source + .match_indices("#[cfg(test)]") + .find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod "))) + .map_or(source.len(), |(at, _)| at); + let source = &source[..cut]; + let code = code_only(source); + let line_of = |at: usize| code[..at].matches('\n').count() + 1; + let check = |at: usize, arg: &str, what: &str| { + // A single character is a symbol or a glyph, not interface text - the same exemption + // the call-shape scan above makes. A call that already asks the catalog is not a + // literal here at all. + if let Some(text) = literal_text(arg) + && text.chars().count() >= 2 + && !VERBATIM.contains(&text.as_str()) + { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at)); + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + for (at, _) in code.match_indices(&marker) { + if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) { + check(at, arg, &format!("widgets::{call}")); + } + } + } + for (at, _) in code.match_indices(".text(") { + if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) { + check(at, arg, "Painter::text"); + } + } + } + } + + #[test] + fn the_composition_settings_labels_are_translated() { + // The Composition Settings dialog (and New Composition) draws these from a `(label, value)` + // table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They + // used to have no row in any catalog, so every interface drew them in English. + for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row"); + } + } + } + #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b259bfde..27e24d4da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ mod app; mod clock; mod hooks; +pub use app::{GPU_PREPARING, GpuBuild}; #[cfg(test)] use app::{MAX_PENDING_SHOTS, SCREENSHOT_SETTLE_PASSES, SCREENSHOT_SETTLE_S, SCREENSHOT_TIMEOUT_S}; pub use clock::AppClock; @@ -310,8 +311,17 @@ pub struct EffectcraftApp { /// The GPU compositor on that device (None: no usable adapter → CPU only). pub(crate) gpu: Option, gpu_checked: bool, + /// The GPU compositor being built on a worker thread (desktop): the window opens at once and + /// composites on the CPU until the worker's result lands ([`Self::init_gpu`]). + #[cfg(not(target_arch = "wasm32"))] + gpu_build: Option>, gpu_failures: Option, gpu_failure_applied: Option, + /// Settings ▸ Import ▸ Reload Footage Changed on Disk: the worker that stamps the footage + /// files, started with the first project that has items ([`Self::tick_footage_reload`]). + footage_watcher: Option, + /// The project revision and preference the watched set was last computed for. + footage_watch_key: Option<(u64, String)>, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 0c334f74d..ec9ab9170 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -179,7 +179,12 @@ fn menu_nodes( "recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index 5d65ffb8f..1bd11dabe 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -546,6 +546,8 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> OpenProject, Save(&'static str), Open(&'static [&'static str]), + /// A movie, or a file with one of these extensions. + OpenFootage(&'static [&'static str]), } let (key, ask) = match id { "file.import" | "file.importMultiple" => ("paths", Ask::Import), @@ -560,6 +562,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "view.exportGuides" => ("path", Ask::Save("Guides.json")), "view.importGuides" => ("path", Ask::Open(&["json"])), + "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")), "file.exportLottie" => ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])), @@ -575,9 +578,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => { - ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) - } + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -588,10 +589,12 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> { return None; } + // Importable files besides movies. const MEDIA: &[&str] = &[ - "mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", - "flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", + "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", "flac", "ogg", "opus", "svg", "pdf", "ai", + "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv", ]; + let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::>(); // A host whose blocking dialogs are unsafe inside this frame shows the dialog without // blocking — macOS: the nested event loop re-entered winit's handler on a drag over the // window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped, @@ -600,9 +603,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -623,7 +627,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> } Ask::Import => { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -633,6 +637,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; f(exts).first().map(|s| json!(s)) } + Ask::OpenFootage(more) => { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index b9990786c..f50fbdb3c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -210,6 +210,9 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -222,10 +225,37 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } } +/// Escape with a dialog open: it is Cancel. Escape while recording a shortcut cancels the +/// recording, not the editor; so does Escape in its Delete Preset prompt. +fn cancel_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { + return; + } + if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + return; + } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + return; + } + // Escape in Settings is Cancel: restore the settings from when it opened. + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() + { + app.session.prefs = p; + app.session.prefs_changed(); + } + crate::panels::live_preview::cancel(app); + app.dialog = None; +} + type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); /// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. @@ -341,23 +371,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { - // Escape while recording a shortcut cancels the recording, not the editor; so does - // Escape in its Delete Preset prompt. - if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { - continue; - } - if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - continue; - } - // Escape in Settings is Cancel: restore the settings from when it opened. - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - app.dialog = None; + cancel_dialog(app, ctx); continue; } if app.dialog.is_some() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 85d0fb193..7876ec180 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -196,8 +196,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); @@ -243,6 +244,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::polish(app) { + "Usługi" } else if crate::i18n::georgian(app) { "სერვისები" } else { @@ -360,7 +363,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); s.state.open_comps.hash(&mut h); s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); - s.shortcuts().keys.len().hash(&mut h); + // Each binding's keys: a key changed to another one gives the item a new accelerator. + let shortcuts = s.shortcuts(); + for b in &shortcuts.bindables { + b.key.hash(&mut h); + shortcuts.keys.get(&b.key).hash(&mut h); + } s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -578,6 +586,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn polish_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Usługi", "Ukryj EffectCraft", "Ukryj pozostałe", "Pokaż wszystko"]); + } + #[test] fn georgian_translates_macos_predefined_items() { if !cfg!(target_os = "macos") { @@ -722,6 +751,24 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets + /// the new accelerator even though the number of bindings stays the same. + #[test] + fn a_changed_shortcut_changes_the_state_key() { + let mut app = app(); + let script = json!({"name": "Rename Layers.jsx"}); + let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap(); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap(); + app.session.shortcuts_changed(); + let k0 = state_key(&app); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap(); + app.session.shortcuts_changed(); + assert_ne!(state_key(&app), k0); + let spec = build(&app); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8")); + } + /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it. #[test] fn window_menu_entry_toggles_its_panel() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d386d9784..f6a5b5321 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; @@ -35,6 +36,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub(crate) mod preview_merge; pub mod primitive_dialog; pub mod project; pub mod properties; @@ -331,7 +333,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index b07da8567..e6b62efb4 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..80932f490 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,10 +426,15 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); + let export = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 70.0), vec2(218.0, 22.0)); + if widgets::text_button(ui, export, tr("Export Scene to Blender..."), false, &t, egui::Id::unique(("ct-blender", g.uid))).clicked() && !busy { + actions.push(("camera.exportBlender".into(), me.clone())); + } + app.auto.add(&format!("effectControls.cameraTracker.{}.exportBlender", g.uid), export, "Export Scene to Blender..."); } let status = match app.session.camera_progress() { Some(pr) if running => pr.banner(), @@ -457,4 +462,4 @@ pub fn editor( } /// Height of [`editor`]. -pub const EDITOR_HEIGHT: f32 = 68.0; +pub const EDITOR_HEIGHT: f32 = 100.0; diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 0d07afd94..01fbc5995 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -330,11 +330,11 @@ fn tab_body( let cur = PIXEL_ASPECTS .iter() .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) - .map(|p| p.0.to_string()) + .map(|p| tr(p.0).to_string()) .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { for (name, v) in PIXEL_ASPECTS { - if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() { d.pixel_aspect = *v; } } @@ -402,7 +402,7 @@ fn tab_body( ui.horizontal(|ui| { let r = crate::widgets::srgb_color_button(ui, &mut d.bg); auto.add("dialog.comp.background", r.rect, "Background Color"); - ui.label(color_name(d.bg)); + ui.label(tr(color_name(d.bg))); }); ui.end_row(); }); @@ -487,6 +487,7 @@ pub fn show( auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, ) -> Option { let mut out = None; ui.horizontal(|ui| { @@ -498,7 +499,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; @@ -528,6 +529,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7c89ad32d..2a70d057d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -76,6 +78,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -109,6 +112,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -123,6 +127,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -138,6 +143,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -190,6 +196,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -202,6 +233,11 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT); ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass)); let size = size.min(modal_space(ctx)); @@ -210,10 +246,10 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); @@ -403,9 +439,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, + // so passing the source keeps the dialog in place when the interface language changes. + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -423,11 +465,25 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app, "comp.settings", params.clone()) { + // The Resolution is not part of the command, so the preview never applied it. + if let Some(cid) = app.dialog_state.editing_comp + && let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution) + { + app.ui.status = e.to_string(); + } + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) }; let r = match cid { @@ -442,7 +498,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -499,8 +563,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::unique("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -586,16 +653,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, command, params.clone()) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 0a4636b87..9b3535288 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,12 +44,19 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come -/// from no one place, so they have neither. +/// Whether a light type has Falloff options (distance attenuation): image-based Environment +/// lights and Ambient come from no one place, so they have none. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows +/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an +/// Environment light's shadow comes from its image, not a falloff. +fn casts_shadows(kind: LightKind) -> bool { + kind != LightKind::Ambient +} + fn comp_w(app: &EffectcraftApp) -> f64 { app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) } @@ -92,8 +99,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -142,13 +155,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -160,6 +179,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -168,8 +190,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -222,46 +247,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -306,8 +351,11 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } + } + if casts_shadows(d.kind) { ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows"); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); @@ -318,52 +366,71 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); ui.add_space(14.0); - if falls_off(d.kind) { + if casts_shadows(d.kind) { ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); } - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = d.kind; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if falls_off(kind) { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = d.kind; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if falls_off(kind) { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + } + if casts_shadows(kind) { + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index c95c90c07..bd566cb7f 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui: let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 12f3e0057..122a6abf7 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -192,7 +192,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bp.text( pos2(body.center().x, body.min.y + 40.0), Align2::CENTER_CENTER, - "No effects. Drag one from Effects & Presets.", + crate::i18n::tr("No effects. Drag one from Effects & Presets."), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 44c77ec22..e140e49e1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -376,7 +376,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index c7019492f..89cc3493e 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 64c2ced12..ef6458475 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e121d5d02..29364275b 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec } /// The Interpret Footage dialog for the selected footage (or its proxy), if there is one. -fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> { +fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec)> { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { (_, Some(px)) if proxy => Some(px.footage.clone()), (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()), @@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + *base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; - let Some(form) = interpret_footage_form(s, proxy) else { return false }; + let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false }; form } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s), @@ -1074,4 +1121,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 4ed96797a..85b6be25f 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -600,7 +600,7 @@ fn colorama_wheel( p.text( pos2(sr.min.x, sr.max.y + 9.0), Align2::LEFT_CENTER, - "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"), Tokens::ui(10.5), t.text_faint, ); @@ -801,19 +801,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index e132a42d1..25e3d1d44 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +64,14 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project as the preview left it, to tell edits made since from the preview's. + applied_at: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +234,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -231,6 +248,11 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); + // The preview already set the property to this draft, in one merged undo step: OK keeps it. + if d.applied.as_ref() == Some(&g) { + app.session.history.merge_key = None; + return; + } let Some(tg) = d.target else { return }; if g == d.baseline { return; @@ -241,6 +263,55 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + // An edit made in the same frame as OK is not in the last preview yet. + sync_preview(app, ctx); + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + preview_merge::rollback(app, cp, d.applied_at.as_ref()); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => { + app.dialog_state.gradient.applied = Some(g); + app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + let at = app.dialog_state.gradient.applied_at.take(); + preview_merge::rollback(app, cp, at.as_ref()); + app.dialog_state.gradient.applied = None; + } +} + pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -258,7 +329,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -416,6 +488,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -427,12 +503,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 24df22616..35a745c81 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -48,9 +48,21 @@ struct KeyPt { t: f64, v: f64, sel: bool, + muted: bool, handles: Vec, } +/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. +fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { + let fill = match (k.muted, k.sel) { + (true, true) => t.keyframe_selected.gamma_multiply(0.5), + (true, false) => Color32::from_gray(0x70), + (false, true) => t.keyframe_selected, + (false, false) => Color32::from_gray(0xe0), + }; + (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) +} + struct Curve<'a> { layer: &'a Layer, prop: &'a Property, @@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; - if ks && show && (bezier || speed) { + if ks && !k.muted && show && (bezier || speed) { handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); } } @@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl } else { k.value.components().get(d).copied().unwrap_or(0.0) }; - keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles }); } out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); } @@ -221,7 +233,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -251,14 +263,16 @@ struct BarBtn<'a> { fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool { let BarBtn { label, on, id, tip } = btn; let t = app.tokens; - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -270,7 +284,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, b Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() @@ -522,9 +536,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + let (col, edge) = key_colors(&t, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); - pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 21e0e7fa2..b646b002e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -25,10 +25,11 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { - project: std::sync::Arc, - history: effectcraft_engine::history::History, +pub(crate) struct Checkpoint { + pub(crate) project: std::sync::Arc, + pub(crate) history: effectcraft_engine::history::History, } impl std::fmt::Debug for Checkpoint { @@ -37,7 +38,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -274,25 +275,32 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - if changed { - // Snapshot only when Cancel has something to restore: opening and cancelling without - // edits must not copy the whole TextDoc. - let edited = active_editor_snapshot(&app.session); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_after_replacement(edited.as_ref()); - app.session.bump(); - } else { - // No edit was committed, so nothing can have become invalid. In particular, do not - // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. - app.session.history = checkpoint.history; - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + if changed { + // Snapshot only when Cancel has something to restore: opening and cancelling without + // edits must not copy the whole TextDoc. + let edited = active_editor_snapshot(&app.session); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); + } else { + // No edit was committed, so nothing can have become invalid. In particular, do not + // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. + app.session.history = checkpoint.history; + } + app.session.history.merge_key = None; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a3c4a85f6..fcb98cce1 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."), Tokens::ui(11.5), t.text_faint, ); @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..83d0d2836 --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,129 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, + /// The project as that command left it, to tell edits made since from the preview's. + applied_at: Option, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => { + app.dialog_state.live.applied = Some((command, params)); + app.dialog_state.live.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + let at = app.dialog_state.live.applied_at.take(); + // Only what the preview did is taken back: an edit made while the dialog was open stays. + preview_merge::rollback(app, cp, at.as_ref()); + } + app.dialog_state.live.applied = None; + app.dialog_state.live.applied_at = None; +} + +/// OK: the preview's result is the edit. The preview is first brought up to `command` with +/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last +/// preview). Returns whether the project then already has the edit (the caller must not run the +/// command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool { + sync(app, command, params); + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 65e188c90..2fec0e4c2 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that @@ -42,7 +43,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))) + .on_hover_text(tr(tip)) + .clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs new file mode 100644 index 000000000..ab41e3167 --- /dev/null +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -0,0 +1,204 @@ +//! Taking a dialog's Preview back without taking other edits with it. +//! +//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the +//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the +//! project changed in the meantime for another reason (a camera analysis finishing, a script, the +//! control channel), swapping the checkpoint in would throw that edit away. This module undoes +//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project +//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch +//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched +//! keeps the other edit. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use effectcraft_engine::commands::text_edit::active_editor_snapshot; +use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project}; + +use crate::EffectcraftApp; +use crate::panels::layer_styles_dialog::{Checkpoint, restore}; + +/// The project right after a preview was applied, and the undo depth then. +#[derive(Clone)] +pub(crate) struct Applied { + project: Arc, + undo_len: usize, +} + +impl std::fmt::Debug for Applied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive() + } +} + +/// Note the project as the preview that has just been applied left it. +pub(crate) fn applied(app: &EffectcraftApp) -> Applied { + Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() } +} + +/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is +/// still what the preview made (or nothing says what that was) the checkpoint is simply restored. +pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) { + let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else { + restore(app, checkpoint); + return; + }; + let merged = merge(&checkpoint.project, &applied.project, &app.session.project); + // One step for what was done meanwhile, from the project before the dialog: Undo takes it back. + let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string()); + let mut history = checkpoint.history; + history.undo.push((label, checkpoint.project)); + history.redo.clear(); + history.merge_key = None; + let edited = active_editor_snapshot(&app.session); + app.session.project = Arc::new(merged); + app.session.history = history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); +} + +/// `now` without the changes between `before` and `after`, keeping any other change. +fn merge(before: &Project, after: &Project, now: &Project) -> Project { + let mut merged = now.clone(); + let ids: BTreeSet = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect(); + for id in ids { + match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) { + Some(it) => { + merged.items.insert(id, it); + } + None => { + merged.items.remove(&id); + } + } + } + merged +} + +/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the +/// preview goes back to `before`, touched by both is `both`. +fn pick(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option) -> Option { + if after == before { + now.cloned() + } else if now == after { + before.cloned() + } else { + both() + } +} + +fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option { + pick(before, after, now, || { + if let (Some(b), Some(a), Some(n)) = (before, after, now) + && let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind) + { + let mut merged = n.clone(); + // Name, folder and the like: only a preview that changed the item itself and nothing else + // is taken back; the comp's own contents are merged below. + merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc))); + return Some(merged); + } + now.cloned() + }) +} + +fn bare(c: &Comp) -> Comp { + let mut b = c.clone(); + b.layers.clear(); + b +} + +fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> { + c.layers.iter().find(|l| l.id == id) +} + +fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { + // The comp's own settings, apart from its layers. + let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now)); + let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned()); + // Layers as they stand now, in their order, then the ones the preview removed put back. + let mut layers: Vec = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect(); + for (at, l) in before.layers.iter().enumerate() { + if layer_of(now, l.id).is_none() + && let Some(back) = merge_layer(l.id) + { + layers.insert(at.min(layers.len()), back); + } + } + merged.layers = layers.into(); + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use crate::panels::live_preview; + use effectcraft_engine::Session; + use serde_json::json; + + fn app_with_light() -> (EffectcraftApp, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + (EffectcraftApp::new(s), light) + } + + fn name_of(app: &EffectcraftApp, layer: u64) -> String { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone() + } + + fn layer_count(app: &EffectcraftApp) -> usize { + app.session.active_comp().unwrap().layers.len() + } + + /// The preview renames the light; a null is added meanwhile (what a finished background job + /// would do). Cancel takes the rename back and keeps the null, with one undo step for it. + #[test] + fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + let rename = |name: &str| json!({"layer": light, "name": name}); + live_preview::sync(&mut app, "layer.rename", rename(&original)); + live_preview::sync(&mut app, "layer.rename", rename("Previewed")); + assert_eq!(name_of(&app, light), "Previewed"); + let layers = layer_count(&app); + app.session.execute("layer.newNull", json!({})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original, "the preview is taken back"); + assert_eq!(layer_count(&app), layers + 1, "the other edit stays"); + assert!(app.session.undo()); + assert_eq!(layer_count(&app), layers, "and has its own undo step"); + assert_eq!(name_of(&app, light), original); + } + + /// With nothing else done meanwhile the checkpoint is restored as before. + #[test] + fn cancelling_an_undisturbed_preview_restores_the_checkpoint() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + let steps = app.session.history.undo.len(); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original); + assert_eq!(app.session.history.undo.len(), steps); + } + + /// An item both the preview and another edit changed keeps the other edit. + #[test] + fn a_layer_both_edits_changed_keeps_the_other_edit() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), "Renamed elsewhere"); + } +} diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1944f333f..cdf57de27 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -812,7 +812,7 @@ fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip))); if resp.clicked() { @@ -1230,12 +1230,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim); proxy_indicator(app, ui, &g, it, &mut actions); - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..321224c64 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -474,7 +494,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -487,7 +507,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } } diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index affd46317..7a2cecd30 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( out_rect.center(), Align2::CENTER_CENTER, - "JavaScript with the After Effects scripting object model — try app.project.numItems", + crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index d12309b02..753b2884e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -26,6 +26,8 @@ enum Act { Close(u32), /// A mouse event (`scriptui.mouse` parameters without window and widget). Mouse(u32, u32, Value), + /// A docked panel's new size (`scriptui.resize`). + Resize(u32, [f32; 2]), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -395,7 +397,11 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & return; } let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let (latest, events, mods, time, held) = ui.input(|i| { + let held = |b: egui::PointerButton, bit: u8| if i.pointer.button_down(b) { bit } else { 0 }; + let held = held(egui::PointerButton::Primary, 1) | held(egui::PointerButton::Secondary, 2) | held(egui::PointerButton::Middle, 4); + (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time, held) + }); let ctx = ui.ctx(); let layer = ui.layer_id(); let target = |p: egui::Pos2| -> Option<&Widget> { @@ -414,7 +420,7 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & w.id, json!({ "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, - "button": button, "detail": detail, + "button": button, "buttons": held, "detail": detail, "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, }), )); @@ -431,10 +437,15 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & } st.hover = now_id; } - if let Some((w, p)) = now - && st.at != Some(p) - { - send(w, "mousemove", p, 0, 0, acts); + // While a button is held, the control it was pressed on keeps getting the moves, inside or + // outside the window, as in After Effects (#2584). + let captured = st.down.and_then(|(c, _)| root.find(c)); + if let Some(p) = latest.filter(|p| st.at != Some(*p)) { + match (captured, now) { + (Some(c), _) => send(c, "mousemove", p, 0, 0, acts), + (None, Some((w, _))) => send(w, "mousemove", p, 0, 0, acts), + (None, None) => {} + } } st.at = latest; // Presses and releases. @@ -446,7 +457,9 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & egui::PointerButton::Secondary => 2, _ => continue, }; - let Some(w) = target(*pos) else { + // A release goes to the control the button was pressed on, wherever the pointer is. + let pressed_on = st.down.filter(|(_, db)| !*pressed && *db == b).and_then(|(c, _)| root.find(c)); + let Some(w) = pressed_on.or_else(|| target(*pos)) else { if !pressed { st.down = None; } @@ -460,7 +473,8 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & send(w, "mousedown", *pos, b, st.clicks, acts); } else { send(w, "mouseup", *pos, b, st.clicks, acts); - if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + let over = target(*pos).is_some_and(|t| t.id == w.id); + if over && st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { // The click with the pointer's details, after mousedown / mouseup, in place of // the plain click the control reported. acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); @@ -488,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})), Act::Mouse(w, id, mut v) => { if let Some(o) = v.as_object_mut() { o.insert("window".into(), json!(w)); @@ -570,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { // A modal script dialog blocks the panels too. let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; + // The script learns its dock's size when it changes (#2585), as After Effects tells a + // docked panel: its window takes the size and onResize runs. + let dock = [rect.width().round(), rect.height().round()]; + let told = egui::Id::unique(("scriptui-dock-size", id)); + if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 { + ui.data_mut(|d| d.insert_temp(told, dock)); + acts.push(Act::Resize(id, dock)); + } let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f4dd89bfc..562a90132 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { - let mut x = v.as_i64().unwrap_or(0); - // A typed number applies once entered: typing 85 into UI Scale - // doesn't resize the interface to 75 % at its first digit. + // A dragged value applies on release, and a typed one once + // entered: UI Scale doesn't resize the interface under the + // pointer mid-drag (#2582) or at a typed number's first digit. + let held = egui::Id::unique(("settings-drag", key)); + let mut x = ui.data(|d| d.get_temp::(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0)); let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(tr(unit)).color(t.text_dim)); } - if r.changed() { + if r.dragged() { + ui.data_mut(|d| d.insert_temp(held, x)); + } else if r.changed() || r.drag_stopped() { + ui.data_mut(|d| d.remove::(held)); acts.push(Act::Set(key.into(), json!(x))); } r diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 2721c6750..60b908069 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ff8778a15..5a1ac35a0 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Start from a ready-made project. Opening a template makes an untitled copy.", + crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."), Tokens::ui(11.5), t.text_faint, ); // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 38c93ebd4..2e7e62a08 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -654,6 +654,17 @@ struct FontSearch { } /// Shared font menu for Character and Properties: search, recent fonts and previews. +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); app.auto.add(auto, rect, "Font family"); @@ -724,6 +735,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app_ref.ui.status = format!("{}…", tr("Rescan Fonts")); + } + app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let toggled = row.clicked() || boxed.clicked(); if toggled { only_variable = !only_variable; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 98bc4d8d2..9f308e4be 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1528,8 +1528,8 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele let anchor = ctx.data(|d| d.get_temp::(anchor_id(cid))).or_else(|| selected.first().copied())?; let a = comp.layers.iter().position(|l| l.id == anchor)?; let b = comp.layers.iter().position(|l| l.id == layer)?; - let span = comp.layers.get(a.min(b)..=a.max(b))?; - Some(span.iter().filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) + let span = comp.layers.iter().skip(a.min(b)).take(a.max(b) - a.min(b) + 1); + Some(span.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) } /// Set on the frame the reorder drag is released. @@ -1772,7 +1772,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -2213,7 +2213,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T let comp = g.comp.clone(); let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -3591,7 +3591,15 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { @@ -3622,7 +3630,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let RowKind::Waveform { item, .. } = &rc.row.kind else { return }; lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); @@ -3632,7 +3640,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { gp.text( pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y), Align2::LEFT_CENTER, - "Building waveform…", + crate::i18n::tr("Building waveform…"), Tokens::ui(11.0), t.text_faint, ); @@ -4111,6 +4119,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + (if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})), ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), @@ -4203,6 +4212,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe None if ks => t.keyframe_selected, None => t.keyframe, }; + // Muted keys stay visible and editable but no longer drive the value. + let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). gp.circle_filled(c, 2.5, kcol); @@ -5128,6 +5139,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"), + ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 12259abf7..9a5c89f7d 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() { " Size", " Position", " Roundness", + " Per-Corner Roundness", + " Top Left Roundness", + " Top Right Roundness", + " Bottom Right Roundness", + " Bottom Left Roundness", " >Fill 1", " Composite", " Fill Rule", diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 3edde09df..dac9f7d2b 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -626,7 +626,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = craft_ui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index b5fc582b2..7befef4e2 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -16,7 +16,16 @@ use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; /// Commands that replace or close the open project (Quit is asked when the window closes). -const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"]; +const CLOSING: &[&str] = &[ + "file.newProject", + "file.open", + "file.openRecent", + "file.closeProject", + "file.revert", + "file.openDemoProject", + "templates.create", + "file.runShutdownScripts", +]; /// The command waiting on the prompt, and whether the user already answered it. #[derive(Clone, Debug, Default)] @@ -239,6 +248,23 @@ mod tests { assert!(app.session.is_dirty()); } + #[test] + fn shutdown_scripts_prompt_before_discarding_unsaved_work() { + let (mut app, ctx) = app(); + modified(&mut app); + let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(result, json!({"dialog": "unsavedChanges"})); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + answer_with(&mut app, &ctx, Answer::Cancel); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + answer_with(&mut app, &ctx, Answer::Discard); + assert_eq!(app.session.project.comps().count(), 0); + assert!(!app.session.is_dirty() && app.dialog.is_none()); + } + #[test] fn closing_a_modified_project_asks_and_cancel_keeps_it() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 8c619c9c1..d084dd006 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -699,16 +699,9 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; - if notices >= 3 { - break; - } + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. + for (notices, (layer, why)) in footage_problems(app, ectx, comp, time).into_iter().enumerate() { let (m, _) = l2c(ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -720,7 +713,6 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } v.vertex_marks = vertex_marks; v.track_hits = track_hits; @@ -728,6 +720,20 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) { v.has_3d = has_3d; } +/// Up to three layers active at `time` whose footage, or the proxy in use for footage or a +/// precomp, can't be decoded, each with the reason. +fn footage_problems<'c>(app: &EffectcraftApp, ectx: &EvalCtx, comp: &'c Comp, time: Tick) -> Vec<(&'c Layer, String)> { + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, ectx.project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() +} + fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) { let t = v.t; let cid = v.cid; diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..65095b399 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -9,13 +9,13 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); // A Project item dropped on the empty viewer creates the first composition, diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ef4549db6..ab6d3d561 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -360,31 +395,36 @@ pub(crate) fn motion_path( } let v = k.value.as_vec3(); let s = to_scr(v); - // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's - // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) - }; - for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); - if !has || (tg[0] == 0.0 && tg[1] == 0.0) { - continue; + let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + // A muted position key is off the motion path: no tangents, and a dimmed point. + let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + if !k.muted { + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, kc)); + painter.circle_filled(h, 3.0, kc); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } - let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); - painter.line_segment([s, h], Stroke::new(1.0, col)); - painter.circle_filled(h, 3.0, col); - let side = if out { "out" } else { "in" }; - app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { - painter.rect_filled(r, 0.0, col); + if selected { + painter.rect_filled(r, 0.0, kc); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); @@ -680,6 +720,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..ac3368b2e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -602,7 +602,7 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] pub fn layer_picker( ui: &mut Ui, rect: Rect, - layers: &[effectcraft_engine::project::Layer], + layers: &effectcraft_engine::project::Layers, current: Option, t: &Tokens, id: egui::Id, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 54f641687..7d38e3089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -16,11 +16,13 @@ mod ui_character_eyedropper; mod ui_color_hex; mod ui_comp_presets; mod ui_control_modifiers; +mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_env_light_gpu; mod ui_expression_values; mod ui_font_search; mod ui_form_fit; diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index f7d349342..8c4be61a7 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -16,8 +16,9 @@ const H: f64 = 360.0; fn app() -> (EffectcraftApp, u64, u64) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "Shot", "width": W, "height": H, "duration": 2})).unwrap(); - let plate = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Shot", "width": W as u32, "height": H as u32, "duration": 2, "frameRate": 30})).unwrap(); + let plate = + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": W as u32, "height": H as u32})).unwrap()["layer"].as_u64().unwrap(); let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap(); let uid = r["effects"][0].as_u64().unwrap(); // A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1. @@ -135,7 +136,11 @@ fn viewer_points_selection_and_effect_controls() { h.state_mut().show_panel(PanelKind::EffectControls); h.run_steps(4); let all = ids(&h); - for want in [format!("effectControls.cameraTracker.{uid}.analyze"), format!("effectControls.cameraTracker.{uid}.createCamera")] { + for want in [ + format!("effectControls.cameraTracker.{uid}.analyze"), + format!("effectControls.cameraTracker.{uid}.createCamera"), + format!("effectControls.cameraTracker.{uid}.exportBlender"), + ] { assert!(all.contains(&want), "missing {want}"); } // Tracker panel: Track Camera is available for the selected layer. @@ -145,6 +150,56 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +#[test] +fn blender_export_button_opens_python_dialog_and_writes_scene() { + use effectcraft_engine::project::{Footage, FootageKind, ItemKind, LayerId, LayerSource}; + let (mut a, plate, uid) = app(); + let cid = a.session.active_comp_id().unwrap(); + a.session + .edit("video fixture", None, |p, _| { + let item = p.add_item( + "Video", + Default::default(), + None, + ItemKind::Footage(Footage { + path: "/tmp/effectcraft-ui-synthetic.mp4".into(), + kind: FootageKind::Video, + width: W as u32, + height: H as u32, + has_video: true, + duration: effectcraft_engine::time::Tick::from_seconds_f64(2.0), + ..Default::default() + }), + ); + p.comp_mut(cid).unwrap().layer_mut(LayerId(plate)).unwrap().source = LayerSource::Footage { item }; + Ok(()) + }) + .unwrap(); + a.session.camera_pending.clear(); + let output = std::env::temp_dir().join(format!("effectcraft-ui-blender-{}.py", std::process::id())); + let selected_path = output.clone(); + a.hooks.pick_save_file = Some(Box::new(move |name, ext| { + assert!(name.ends_with(".py")); + assert_eq!(ext, "py"); + Some(selected_path.to_string_lossy().into_owned()) + })); + let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let builder = if shot.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::EffectControls); + h.run_steps(5); + let button = rect(&h, &format!("effectControls.cameraTracker.{uid}.exportBlender")); + if let Some(path) = shot { + h.render().expect("render export button").save(path).unwrap(); + } + click(&mut h, button.center(), Default::default()); + assert!(output.exists(), "UI export failed: {}", h.state().ui.status); + let script = std::fs::read_to_string(output).expect("clicking the export button saves a Python scene"); + assert!(script.contains("def import_scene():")); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project"); +} + /// Drag with `button` from `from` to `to` in steps, then release. fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { h.input_mut().events.push(egui::Event::PointerMoved(from)); diff --git a/crates/ui-egui/tests/it/ui_dialog_preview.rs b/crates/ui-egui/tests/it/ui_dialog_preview.rs new file mode 100644 index 000000000..ce5f2803e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_dialog_preview.rs @@ -0,0 +1,303 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId, Resolution}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The Resolution is no part of the Composition Settings command, so a preview never applies it: +/// OK has to, also when it keeps a preview of another field. +#[test] +fn composition_settings_ok_after_a_preview_still_applies_the_resolution() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the preview applied the width"); + h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 80); + assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution"); +} + +/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the +/// project up to the draft it finds instead of keeping the older preview. +#[test] +fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index ec8db35cc..5608dfcf3 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -236,7 +236,7 @@ fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); drag(&mut h, from, to, Modifiers::NONE); assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); - for layer in &h.state().session.active_comp().unwrap().layers[..2] { + for layer in h.state().session.active_comp().unwrap().layers.iter().take(2) { let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); @@ -632,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() { let _ = std::fs::remove_dir_all(&dir); } +/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file +/// (through the Import Image Sequence question a numbered still asks) and makes a composition +/// of it, as in After Effects. +#[test] +fn new_composition_from_footage_makes_a_comp_of_the_picked_file() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let dir = frames("fromfootage", 2); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + click_id(&mut h, "viewer.empty.import"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (64, 32)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["frame_0002.png"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret /// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in /// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rs new file mode 100644 index 000000000..e88f0a2a4 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs @@ -0,0 +1,84 @@ +//! The Environment light through the app's GPU compositor (egui_kittest with wgpu): adding one +//! visibly changes the viewer's GPU frames (#2436). Skips without a GPU adapter (CI). + +use effectcraft_engine::Session; +use effectcraft_engine::project::Renderer; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +use crate::ui_gpu::wait_for_compositor; + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +/// egui_kittest's wgpu setup on the adapter the headless compositor picked, so the viewer's GPU +/// compositor attaches (see ui_gpu.rs). +fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let fallback = c.native_adapter_selector.take(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name; + match (adapters.iter().find(named), &fallback) { + (Some(a), _) => Ok(a.clone()), + (None, Some(f)) => f(adapters, surface), + (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()), + } + })); + } + setup +} + +/// A red Environment light at full intensity lights the scene on the GPU compositor too: the +/// viewer's GPU frames change when the light is added. +#[test] +fn environment_light_lights_the_scene_on_the_gpu() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + eprintln!("headless compositor adapter: {adapter}"); + let gl = adapter.ends_with("(Gl)"); + drop(probe); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .wgpu_setup(same_adapter(adapter)) + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached, so the + // frames compared below are GPU frames, not ones the CPU composited meanwhile (see ui_gpu.rs). + wait_for_compositor(&mut h); + if gl && h.state().gpu_adapter().is_none() { + eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); + return; + } + assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); + let s = &mut h.state_mut().session; + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + settle(&mut h); + let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("GPU frame after adding the light").pixels.clone(); + + assert_eq!(before.len(), after.len()); + let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count(); + assert!(diff > 500, "a red Environment light at 100 % changed only {diff} GPU pixels"); +} diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 141d8ec58..77984cf6b 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -256,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() { click(&mut h, "character.font.option.JetBrains Mono"); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index 355c35bf5..643d99492 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -6,10 +6,11 @@ use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; +/// Step until the GPU compositor's worker is done and a frame has landed in the viewer. fn settle(h: &mut Harness<'_, EffectcraftApp>) { - for _ in 0..600 { + for _ in 0..3000 { h.step(); - if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + if !h.state().gpu_initializing() && h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { break; } std::thread::sleep(std::time::Duration::from_millis(20)); @@ -19,6 +20,117 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { } } +/// Step until the GPU compositor's worker has handed its result over (attached, or why not). +/// The first step starts the build. +pub(crate) fn wait_for_compositor(h: &mut Harness<'_, EffectcraftApp>) { + h.step(); + for _ in 0..3000 { + if !h.state().gpu_initializing() { + return; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the GPU compositor's worker gave no result within a minute"); +} + +/// Whether wgpu finds any adapter at all (egui_kittest's wgpu harness needs one; CI runners +/// without a GPU skip). +fn any_adapter() -> bool { + let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle()); + !pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all())).is_empty() +} + +fn small_session() -> Session { + let mut s = Session::default(); + let comp = s.execute("comp.new", json!({"name": "First", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"comp": comp, "color": "#ff0000"})).unwrap(); + s +} + +/// The window opens before the GPU compositor exists: its pipelines compile on a worker thread +/// (tens of seconds on a cold shader cache), and until then the interface draws, says so, and +/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable. +#[test] +fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let (release, gate) = std::sync::mpsc::channel::<()>(); + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + // The status line's 5 s expiry reads the app clock: a manual one makes the steps below + // stand for a known span of time. + app.use_manual_clock(); + app.hooks.before_gpu_build = Some(Box::new(move || { + let _ = gate.recv(); + })); + app + }); + // The first frames paint, and a CPU frame reaches the viewer, while the worker still waits. + for _ in 0..600 { + h.step(); + if h.state().is_ready() && !h.output().shapes.is_empty() && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().gpu_initializing(), "the compositor is still being built"); + assert!(!h.output().shapes.is_empty(), "the interface draws before the compositor exists"); + assert!(h.state().session.accel.is_none(), "nothing renders on a compositor that doesn't exist yet"); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line is honest about it"); + assert_eq!(h.state().session.accel_note.as_deref(), Some(effectcraft_ui_egui::GPU_PREPARING)); + assert!(h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false), "a frame was composited on the CPU meanwhile"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows that frame"); + // Other statuses clear after 5 s; this one stays for as long as the build takes (each step + // is a quarter of a second). + for _ in 0..30 { + h.state_mut().clock_mut().advance(0.25); + h.step(); + } + assert!(h.state().gpu_initializing()); + assert_eq!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the status line still says so 7.5 s later"); + release.send(()).unwrap(); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert_ne!(h.state().ui.status, effectcraft_ui_egui::GPU_PREPARING, "the preparing status goes away"); + match h.state().gpu_adapter() { + Some(adapter) => { + eprintln!("compositor attached after the first frames: {adapter}"); + assert!(h.state().session.accel.is_some()); + assert!(h.state().session.accel_note.is_none()); + } + // An adapter that can't run the compositor (software GL): the reason is recorded. + None => assert!(h.state().session.accel_note.as_deref().is_some_and(|n| n != effectcraft_ui_egui::GPU_PREPARING)), + } +} + +/// A graphics backend that panics while the compositor's pipelines compile panics on the worker +/// thread, not under the UI: the window keeps drawing on the CPU and says why. +#[test] +fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() { + if !any_adapter() { + eprintln!("no wgpu adapter: skipping"); + return; + } + let s = small_session(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).wgpu().build_eframe(move |_| { + let mut app = EffectcraftApp::new(s); + app.hooks.before_gpu_build = Some(Box::new(|| panic!("synthetic shader compiler panic"))); + app + }); + settle(&mut h); + assert!(!h.state().gpu_initializing()); + assert!(h.state().gpu_adapter().is_none()); + assert!(h.state().session.accel.is_none()); + let note = h.state().session.accel_note.clone().unwrap_or_default(); + assert!(note.contains("synthetic shader compiler panic"), "{note}"); + assert!(!h.output().shapes.is_empty(), "the interface still draws"); + assert!(h.state_mut().viewer_pixels().is_some(), "the viewer shows a CPU frame"); +} + /// egui_kittest's wgpu setup on the adapter the headless compositor picked (`name (Backend)`), /// like the desktop app's window. Left alone, egui_kittest prefers a CPU adapter, which on /// Windows is WARP on DirectX 12, where FXC fails to compile the compositor's kernels: the @@ -50,21 +162,24 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { // egui-wgpu creates its GL device with WebGL2's limits, which the compositor declines. let gl = adapter.ends_with("(Gl)"); drop(probe); - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).unwrap(); - s.execute("time.set", json!({"time": 2.0})).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1400.0, 900.0)) .with_pixels_per_point(1.0) .wgpu_setup(same_adapter(adapter)) - .build_eframe(|_| EffectcraftApp::new(s)); - settle(&mut h); + .build_eframe(|_| EffectcraftApp::new(Session::default())); + // The project opens once the compositor (built on a worker thread) is attached. A frame the + // CPU composited while it was being built has the GPU frame's key, so the viewer would keep + // showing it, and the comparison below would be the CPU against itself. + wait_for_compositor(&mut h); if gl && h.state().gpu_adapter().is_none() { eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); return; } assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); eprintln!("viewer compositor adapter: {:?}", h.state().gpu_adapter()); + h.state_mut().session.execute("file.openDemoProject", json!({})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 2.0})).unwrap(); + settle(&mut h); let gpu_px = h.state_mut().viewer_pixels().expect("read back the GPU frame"); let gpu_shot = h.render().expect("render"); // Mercury Software Only: the same frame from the CPU. diff --git a/crates/ui-egui/tests/it/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs index fa8c7b73f..f5ee8634b 100644 --- a/crates/ui-egui/tests/it/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/it/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,98 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds. +#[test] +fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops"); + h.state_mut().gradient_draft_mut().add(Track::Color, 0.75); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx); + assert_eq!(h.state().dialog, None); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found"); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index fe1392be1..aac090802 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,13 +177,33 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { invoke(&mut h, "layer.newLight", json!({})); for k in LightKind::ALL { choose(&mut h, k); - let shown = !matches!(k, LightKind::Ambient | LightKind::Environment); - for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] { - assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label()); + // Falloff is distance attenuation: image-based Environment lights and Ambient have none. + let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); + assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + // Cast Shadows shows for every light but Ambient, Environment included (#2436). + let shadows = k != LightKind::Ambient; + for label in ["Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the +/// engine stores on the layer. +#[test] +fn an_environment_light_takes_cast_shadows_from_the_dialog() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + click_id(&mut h, "dialog.light.casts"); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + let l = layer(&h, id); + assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer"); + assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none()); +} + #[test] fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index 424b75c48..8501f6b52 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -265,10 +265,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { std::fs::remove_dir_all(dir).unwrap(); } -/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and -/// Avid), which the Media Browser lists and the importer reads. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -279,11 +279,15 @@ fn import_dialog_offers_mxf() { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); - assert!(result.is_null(), "cancelled: nothing imported"); - let offered = offered.borrow(); - assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); - assert!(offered.iter().any(|e| e == "mov")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews diff --git a/crates/ui-egui/tests/it/ui_new_primitive.rs b/crates/ui-egui/tests/it/ui_new_primitive.rs index fbb3a34e5..c40aa23ed 100644 --- a/crates/ui-egui/tests/it/ui_new_primitive.rs +++ b/crates/ui-egui/tests/it/ui_new_primitive.rs @@ -70,7 +70,7 @@ fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { } fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { - h.state().session.active_comp().unwrap().layers.clone() + h.state().session.active_comp().unwrap().layers.to_vec() } fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..6d06c6bf7 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() { assert_eq!(fx.name, "Radial Wipe"); assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); } + +/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value +/// applies once the button is released. +#[test] +fn ui_scale_applies_when_the_drag_ends() { + let mut h = harness(); + open_settings(&mut h); + let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0))); + h.step(); + assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag"); + } + h.input_mut().events.push(Event::PointerButton { + pos: from + egui::vec2(80.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(4); + let (pct, zoom) = scale(&h); + assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0)); +} diff --git a/crates/ui-egui/tests/it/ui_project_rows.rs b/crates/ui-egui/tests/it/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/it/ui_project_rows.rs +++ b/crates/ui-egui/tests/it/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index aa439737b..0a924bb65 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] @@ -264,6 +300,79 @@ fn script_windows_send_mouse_events_to_listeners() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2584: a drag that starts on a control keeps reaching it outside the script window (moves and +/// the release), and `MouseEvent.buttons` tells a drag from a hover. +#[test] +fn a_drag_keeps_its_control_past_the_window_edge() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Drag"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var seen = []; + var rec = function (e) { seen.push(e.type + (e.clientX < 0 ? " out" : " in") + " B" + e.buttons); log.text = seen.join("|"); }; + pad.addEventListener("mousedown", rec); + pad.addEventListener("mousemove", rec); + pad.addEventListener("mouseup", rec); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "drag.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let (from, to) = (pad.min + vec2(10.0, 20.0), pad.min + vec2(-300.0, 20.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let log = h.state().session.script_ui.windows[0].root.lookup(&json!("log")).cloned().unwrap().text; + assert!(log.starts_with("mousemove in B0|mousedown in B1|"), "a hover move has no button held: {log}"); + assert!(log.contains("mousedown in B1|mousemove out B1"), "moves outside the window still arrive, with the button held: {log}"); + assert!(log.ends_with("|mouseup out B0"), "the release outside reaches the control: {log}"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + +/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked +/// ScriptUI panel gets its dock's size that way. +#[test] +fn script_windows_learn_the_size_they_are_given() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Sized"); + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [200, 20]; + w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(2); + h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap(); + let win = h.state().session.script_ui.windows[0].clone(); + assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180"); + let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}"); + assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + // A docked panel: its window is its dock's size, not its preferred size. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(4); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone(); + let dock = rect(&h, &format!("scriptui.{}", panel.id)); + assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]); + h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id)); + h.run_steps(2); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index aadb52ffe..ae1e5a17f 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -76,6 +76,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test] +fn shape_contents_add_menu_adds_wiggle_transform() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window"); + let entry = h.get_by_label("Wiggle Transform").rect(); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "wiggler"]); +} + /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test] @@ -324,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() { let fill = fill.expect("a fill colour"); fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { for c in g.groups() { + // The rectangle's path rows would push Fill below the rows the Timeline fits; the + // eyedropper only needs Fill, so leave that group collapsed. + if c.name == "Rectangle Path 1" { + continue; + } out.push(c.uid); groups(c, out); } @@ -432,3 +453,42 @@ fn effects_drag_to_a_new_place_in_the_timeline() { h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); } + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +} diff --git a/crates/ui-egui/tests/it/ui_unreadable_footage.rs b/crates/ui-egui/tests/it/ui_unreadable_footage.rs index 9b48b9531..a62884f43 100644 --- a/crates/ui-egui/tests/it/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/it/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,17 +39,25 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +66,39 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} + +/// A precomp whose proxy can't be decoded: the layer says so with Collapse Transformations off +/// or on, since a proxy in use keeps the precomp on the flattened path that draws the proxy. +#[test] +fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}"); + } +} diff --git a/docs/agents.md b/docs/agents.md index 172e5c4c6..7f3fd9ad3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -212,8 +212,25 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built or call `save_project {"path": …}` after each milestone. - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid - layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`) - ([#261](https://github.com/storytold/effectcraft/issues/261)). + layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), + or a default studio environment — bright above and toward the initial camera, dark below and + behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either + way the light brightens the scene. A selected Source or Environment Layer that fails to + render — a deleted layer, missing footage — lights nothing instead: the light never silently + falls back to the studio default. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights: Cast Shadows shades the scene from the image's dominant bright region + (the default environment's key, or a source image's brightest area; an image without a + sufficiently strong dominant direction has none — a uniform one, or opposing bright regions + that cancel — so it casts no shadow) — the region weighted by the light's colour, so a + coloured light shadows from the regions it actually lights (opposing red and blue regions + cancel to a white light, but a red light's shadow comes from the red one) — occluding only + that key's share of the light, so the + shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra + of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights + Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a + Classic 3D comp to Advanced 3D + ([#261](https://github.com/storytold/effectcraft/issues/261), + [#2436](https://github.com/storytold/effectcraft/issues/2436)). ### Cookbook (from end-to-end QA) @@ -446,7 +463,12 @@ and Origin), `camera.createFromSolve {kind: text|solid|null|shadowCatcher, point {center, normal, size?}, multiple?}` (Create Text / Solid / Null / Shadow Catcher, Camera and Light, Create Multiple …), `camera.deletePoints {points?, wait?}` (re-solves; with Auto-delete Points Across Time the same feature's other tracks go too) and `camera.create` (the Create Camera button). -Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. +Each edit is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame. + +`camera.exportBlender {path: ".py", layer?, effect?, sensorWidth?: 36, unitScale?: 0.01, +startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script: +baked camera/lens/focus, synced video background, points and ground. See +[Blender camera export](blender-camera-export.md) for framing, assumptions and limitations. Changing the layer's frames clears the analysis; changing `effects/#1/shotType`, `effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks. @@ -583,7 +605,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). diff --git a/docs/architecture.md b/docs/architecture.md index d19059b01..0bfe4023e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,9 +158,22 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip -chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps -(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep +Environment Layer, or a default studio environment — bright above and toward the initial +camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity +when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed +mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps +(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: +one orthographic from the image's dominant bright region — none only when the image lacks a +sufficiently strong dominant direction (a uniform one, or opposing bright regions that +cancel) — the region weighted by the light's colour, so a coloured light shadows from the +regions it actually lights (opposing red and blue regions cancel to a white light, but a red +light's shadow comes from the red one) — occluding only the image's directional key fraction of +the light, so the shadow stays partial like After Effects' area-light shadows, with a soft +default penumbra of at least eight pixels or four map texels). A Source or Environment Layer +that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback +to the default studio environment. An Environment light only lights Advanced 3D comps, +so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, +as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the diff --git a/docs/blender-camera-export.md b/docs/blender-camera-export.md new file mode 100644 index 000000000..d102eef4f --- /dev/null +++ b/docs/blender-camera-export.md @@ -0,0 +1,101 @@ +# Exporting a tracked camera to Blender + +After the **3D Camera Tracker** finishes solving, click **Export Scene to Blender...** in its +Effect Controls and save the `.py` file. In Blender 4.0 or newer, open it in the Scripting +workspace's Text Editor and choose **Run Script**. The script creates and activates a new scene; +it preserves existing scenes and objects. Save the resulting `.blend` normally. + +The script uses Blender's public Python API, with no add-on or Python dependencies beyond +Blender itself. For unattended use: `blender --background --python "Tracked Scene.py"`. +That imports the scene in memory; it does not automatically save a `.blend`. + +## Transferred data + +- Camera position and quaternion rotation baked on every analyzed frame, with consistent + quaternion signs and linear interpolation; zoom/lens animation, including Variable Zoom. +- Tracking input/video resolution, horizontal sensor fit, exact rational composition FPS, + frame range and composition markers within the analyzed interval. The first analyzed frame + becomes Blender frame 1 by default; the original composition timestamps remain in metadata. +- The original video or still as the camera's background image, at full opacity, refreshed on + frame changes. Source-frame offsets are baked too, accounting for trim, stretch, source FPS + and Time Remap. This is a viewport background, not a render/compositor background. +- Focus distance, DOF switch, f-stop, iris blade count, rotation and aspect sampled from the + tracker's linked **3D Tracker Camera**, including keyframes and expressions. Create that + camera and adjust its Camera Options before exporting to transfer custom focus settings. +- Solved 3D points as a non-rendering vertex mesh, with point attributes for track ID, + reprojection error and first/last visible frame. A chosen ground plane becomes Blender's XY + plane, with its origin preserved and a non-rendering wire plane as a placement guide. +- Shutter duration (not automatically enabled), plus the original shutter phase in metadata. +- A Blender Text datablock named `EffectCraft Tracking Metadata.json` containing the complete + solve, optional original 2D tracks, per-frame timings, source interpretation, diagnostics and + export warnings. Radial distortion coefficients are also camera custom properties. + +Coordinates are converted from EffectCraft's right/down/forward world to Blender's +right/forward/up world, including the camera's local up and negative-Z viewing direction. +Export uses the tracking input's framing, not the footage layer's final 2D placement in the +composition. Camera pose and lens always come from the solve; subsequent manual pose/lens edits +on the linked camera are not substituted for the tracked motion. + +## What cannot be inferred + +Monocular tracking cannot recover absolute scene scale, physical sensor size, optical focus, +f-stop or exposure from feature tracks. The default sensor width is an assumption of **36 mm**; +the focal length in millimeters is derived from it while preserving the solved field of view. +The default scale is **0.01 Blender units per composition-world pixel**, not a measurement. +Without a linked camera, DOF is disabled and focus distance uses the default solved scene depth. +Blender's physical DOF approximates EffectCraft's blur model: Blur Level and Iris Roundness are +retained in metadata, but have no direct Blender equivalent. + +Brown-model radial `k1`/`k2` coefficients use radius normalized to half the image diagonal. +They are preserved, **not** assigned to Blender's different lens-distortion model. For a precise +match when distortion is nonzero, use an undistorted plate; the script prints a warning. +The tracker assumes square pixels, so Blender renders square pixels; any non-square source +interpretation is retained in metadata and flagged. + +The `.py` references the video; it does not contain a copy of the video's bytes. When moving it, +keep the video next to the script (same filename), or relink the background image in Blender. +Missing/unsupported media produces a warning while retaining the imported camera and geometry. +Masks and effects applied before the tracker are not baked into the original media. Export +requires a direct video/still footage layer at the analyzed size; render precomps, sequences or +size-changing preprocessing to a video and track that video first. + +## Agent / control-channel command + +```json +{ + "command": "camera.exportBlender", + "params": { + "layer": "#1", + "path": "/output/Tracked Scene.py", + "sensorWidth": 36, + "unitScale": 0.01, + "startFrame": 1, + "includePoints": true + } +} +``` + +`comp`, `layer` and `effect` select the tracker as with `camera.analyze`. Without explicit +selection, the selected tracker (or first tracker in the composition) is used. `sensorWidth`, +`unitScale`, `startFrame` and `includePoints` are optional. The reply contains file size, +exported frame/point counts and warnings. Writing uses the host's atomic file service. +Calls issued by scripts require **Allow Scripts to Write Files and Access Network** in +Settings ▸ Scripting & Expressions. Export from the UI or control channel remains available +with that preference off. + +Export is limited to 100,000 analyzed frames, 100,000 points and 64 MiB of serialized scene data; +invalid, stale or incomplete solves return an actionable error without modifying the document. + +## Verification + +Engine tests check argument validation, escaped/Unicode paths, fractional FPS, trim/stretch, +focus/f-stop, zoom, ground placement, malformed solves and write failures. To additionally run +the generated artifact against an installed Blender's public API: + +```sh +EFFECTCRAFT_BLENDER=/path/to/blender cargo test -p effectcraft-engine tests::blender -- --nocapture +``` + +Optionally set `EFFECTCRAFT_BLENDER_PLATE` to a local synthetic 640×360 video to exercise media +loading and source-frame offsets. The oracle checks every synthetic point's projection through +the imported camera on every frame, as well as animation, DOF, timing and scene preservation. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e10a242fa..45e373cda 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | none | none | Media Browser lists it | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | diff --git a/docs/footage.md b/docs/footage.md index f23bcdcc7..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR diff --git a/docs/gaps.md b/docs/gaps.md index c851fe0ff..6851fddc1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -72,8 +72,8 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | @@ -310,8 +310,11 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard - shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Each File ▸ Scripts entry can be given a key in + Edit ▸ Keyboard Shortcuts. Missing: a global scope that scripts share for the session (a Startup + script's variables and functions are not available to later scripts) and `.jsxbin`; + `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e980fe50f..59b3f792c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** minor (Polish (`pl`): menus 630, panels 695; recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -17,28 +17,31 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## How EffectCraft translates - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by - `(command, English label)` (624 catalog rows each at 470ea7a3, counted). A test - (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a row whose entry left the menu tree - or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries - and category submenus take their labels from the effect catalog below. + `(command, English label)`. A test (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a + row whose entry left the menu tree or on a repeated row, so every catalog covers all fixed + entries (630 catalog rows each at this tree, counted; 629 in Georgian). The generated Effect menu is not part of + that fixed tree: its entries and category submenus take their labels from the effect catalog + below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (693 rows), Simplified and - Traditional Chinese (695 and 648 since #2244 pruned the Traditional one), Ukrainian (524), - Brazilian Portuguese (695) and Czech (518), counted at this tree. Georgian has every row the - Simplified Chinese catalog has, plus 191 for panels converted to the catalog. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified + Chinese (998), Traditional Chinese (951, after #2244 pruned the table), Ukrainian (524), + Brazilian Portuguese (695), Czech (518) and Spanish (513); counted at this tree. Georgian has + the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since + (885 in all). - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids) and Georgian + (307 rows, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no - panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those - languages draw English until a translator fills them (the Japanese set for #821 is what that - slot is for). Each language decides for itself which names stay English — Adobe's Japanese - interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates - them — so a row repeating the English name is a choice, not a missing row. + panel changes: Traditional Chinese has its table in place and empty, and Japanese fills 297 of + the 307 ids, so the rest draw English until a translator completes them. Each language decides + for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and + `OCIO` sets in English while its Chinese one translates them — so a row repeating the English + name is a choice, not a missing row. - **Missing strings:** a string with no row in the interface language shows in English, and no test, build or release fails on a missing translation. The `missing_translations_report` test (`crates/ui-egui/src/i18n.rs`) prints, per language, the fixed menu entries, panel strings, effect @@ -60,49 +63,52 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27, recounted on 22 October 2026 at `ad8b6acb`, +and recounted again at this tree after #1774 by counting catalog rows (`const` tables in +`crates/ui-egui/src/i18n.rs`, `i18n/ui.rs` and `i18n/effects.rs`: 630 menu rows, 998 +panel/dialog sources in Simplified Chinese, 951 in Traditional Chinese and 996 in Japanese). The +denominator, **≈ 4,200 user-visible strings**, is a rounded estimate: the 630 fixed menu entries, +the 998 panel/dialog sources, 307 effect names, ≈ 1,650 distinct effect parameter names and +≈ 620 other unwrapped interface strings (Home screen literals, engine-built tooltips, error and +status messages). It counts distinct strings, not catalog rows; engine messages are only partly +counted, so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the zh source list; no help, effect names translated (307) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,935 (≈ 46%): menus 630 rows, panels 998, effect names 307 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,322 (≈ 34%): menus 629 rows, panels 693 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 307 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (4): +Other languages EffectCraft ships (5): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,277 (≈ 33%): menus 629 rows, panels 648 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,581 (≈ 38%): menus 630 rows, panels 951 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | +| Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | | Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages -(Simplified Chinese, Traditional Chinese, Japanese and Brazilian Portuguese 34%, Spanish 29%, -French, German and Russian 16%, Korean and Italian 0%; 212 / 10, was ≈ 12% on 10 October; -Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that average). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 24%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 38%, Japanese 46%, Brazilian Portuguese 34%, +Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 241 / 10, was ≈ 21% on +22 October; Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that +average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -112,9 +118,12 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Recounted on the merged tree after #1774: Japanese 1,923 (46%, 297 effect names), Simplified Chinese 1,935 (46%, 307 effect names), Traditional Chinese 1,581 (38%, 951 panel rows after #2244's prune); overall ≈ 24% | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Simplified Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; the 30 October recount above carries the share | | 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | -| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | -| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | -| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-29 | minor | Polish (`pl`): menus 630, panels 695 | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | +| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/plugins.md b/docs/plugins.md index 2117152a1..3b9182a60 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,9 +16,9 @@ Guide): | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | -| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | @@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. * A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts diff --git a/docs/preferences.md b/docs/preferences.md index 2402b9a32..6d692a936 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -120,9 +120,11 @@ Commands (CLI, MCP, control channel): - `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import (they show as colour-bar placeholders, as in After Effects; see [footage.md](footage.md)) - `import.autoReloadFootage`: Automatically Reload Footage, `off`, `nonSequence` (the default, as - in After Effects) or `all` (image sequences too). Every two seconds, - and as soon as the window comes back to the front, the desktop app compares each footage file's - size and modification time with what they were, and reloads what another app saved: a + in After Effects) or `all` (image sequences too). Every two seconds while its window is in + front (every ten seconds while another app has the front), and as soon as the window comes back + to the front, the desktop app compares each footage file's size and modification time with what + they were, on a worker thread that only wakes the interface when something changed, and reloads + what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. @@ -190,7 +192,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 1d808726a..96e298ece 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,13 +20,13 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | -| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | -| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | @@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34829d406..6c0bd82a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,10 +114,10 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 24% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 7, effects in Simplified Chinese and Japanese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) |