Merge main (engine menu tree) into M9.6; wire text animation menus
Animation ▸ Animate Text (Enable Per-character 3D, every animator property, Fill/Stroke Color RGB/Hue/Saturation/Brightness/Opacity submenus, Line Anchor, Character Value), Add Text Selector ▸ Range/Wiggly/Expression (text.addSelector now supports all kinds) and Text Animation Presets after Browse Presets, in the engine menu tree. Stubs replaced: text.perCharacter3d, animatorLineAnchor, animatorCharacterValue, addWigglySelector, addExpressionSelector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
db21e880ef
|
|
@ -97,8 +97,8 @@ dependencies = [
|
|||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"static_assertions",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -149,6 +149,28 @@ version = "0.2.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
|
|
@ -158,7 +180,7 @@ dependencies = [
|
|||
"android-properties",
|
||||
"bitflags 2.13.2",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
|
|
@ -737,6 +759,12 @@ dependencies = [
|
|||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
|
|
@ -869,12 +897,52 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cow-utils"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpu-time"
|
||||
version = "1.0.0"
|
||||
|
|
@ -954,6 +1022,12 @@ dependencies = [
|
|||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
|
|
@ -1053,6 +1127,7 @@ dependencies = [
|
|||
name = "effectcraft"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
"effectcraft-ui-egui",
|
||||
|
|
@ -1122,6 +1197,7 @@ dependencies = [
|
|||
"effectcraft-render",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2127,7 +2203,7 @@ dependencies = [
|
|||
"log",
|
||||
"presser",
|
||||
"thiserror 2.0.21",
|
||||
"windows",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2355,6 +2431,22 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
|
|
@ -2578,6 +2670,15 @@ version = "0.4.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -2739,6 +2840,17 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
|
|
@ -2844,6 +2956,21 @@ dependencies = [
|
|||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.2.2"
|
||||
|
|
@ -2868,6 +2995,28 @@ dependencies = [
|
|||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
|
|
@ -5068,9 +5217,9 @@ dependencies = [
|
|||
"web-sys",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-result",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-result 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5130,6 +5279,16 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
|
@ -5137,7 +5296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
|
@ -5148,7 +5307,17 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5160,7 +5329,7 @@ dependencies = [
|
|||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
|
|
@ -5170,7 +5339,7 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
|
@ -5209,10 +5378,19 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
|
@ -5231,6 +5409,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
@ -5267,6 +5454,21 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5309,6 +5511,12 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5321,6 +5529,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5333,6 +5547,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5357,6 +5577,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5369,6 +5595,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5381,6 +5613,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -5393,6 +5631,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ egui = "0.36"
|
|||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
cpal = "0.16"
|
||||
|
||||
# Local co-development against a FilmCraft checkout next to this repo: uncomment.
|
||||
# [patch."https://github.com/storytold/filmcraft"]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ egui = { workspace = true }
|
|||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
//! Audio preview output through cpal (the default output device).
|
||||
//!
|
||||
//! cpal streams are not `Send` on every platform, so each preview runs its stream on a small
|
||||
//! dedicated thread that owns it until stopped. The callback pulls interleaved stereo from the
|
||||
//! frontend's [`AudioFeed`] and maps it onto the device's channels (mono: L+R average; extra
|
||||
//! channels: silence), converting to the device's sample format.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::{FromSample, SizedSample};
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
|
||||
pub struct CpalOut {
|
||||
rate: u32,
|
||||
latency: Arc<AtomicU64>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// Open the default output device (`None` without one).
|
||||
pub fn open() -> Option<Box<dyn AudioDevice>> {
|
||||
let host = cpal::default_host();
|
||||
let dev = host.default_output_device()?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Box::new(CpalOut { rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None }))
|
||||
}
|
||||
|
||||
fn run<T: SizedSample + FromSample<f32>>(
|
||||
dev: &cpal::Device,
|
||||
cfg: &cpal::StreamConfig,
|
||||
feed: Arc<AudioFeed>,
|
||||
latency: Arc<AtomicU64>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let ch = cfg.channels as usize;
|
||||
let rate = cfg.sample_rate.0 as f64;
|
||||
let mut stereo: Vec<f32> = Vec::new();
|
||||
dev.build_output_stream(
|
||||
cfg,
|
||||
move |out: &mut [T], info: &cpal::OutputCallbackInfo| {
|
||||
let frames = out.len() / ch.max(1);
|
||||
stereo.resize(frames * 2, 0.0);
|
||||
feed.pull(&mut stereo);
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
|
||||
for (c, o) in f.iter_mut().enumerate() {
|
||||
let v = match (ch, c) {
|
||||
(1, _) => (s[0] + s[1]) * 0.5,
|
||||
(_, 0) => s[0],
|
||||
(_, 1) => s[1],
|
||||
_ => 0.0,
|
||||
};
|
||||
*o = T::from_sample(v.clamp(-1.0, 1.0));
|
||||
}
|
||||
}
|
||||
// Frames between "handed over" and "audible": device latency plus this buffer.
|
||||
let ts = info.timestamp();
|
||||
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
|
||||
},
|
||||
|e| eprintln!("effectcraft: audio output: {e}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl AudioDevice for CpalOut {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||||
self.stop();
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
|
||||
let latency = self.latency.clone();
|
||||
let rate = self.rate;
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ec-audio-out".into())
|
||||
.spawn(move || {
|
||||
let host = cpal::default_host();
|
||||
let Some(dev) = host.default_output_device() else {
|
||||
let _ = ready_tx.send(Err("no audio output device".into()));
|
||||
return;
|
||||
};
|
||||
let sup = match dev.default_output_config() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut cfg: cpal::StreamConfig = sup.config();
|
||||
cfg.sample_rate = cpal::SampleRate(rate);
|
||||
let stream = match sup.sample_format() {
|
||||
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency),
|
||||
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency),
|
||||
f => Err(format!("unsupported sample format {f:?}")),
|
||||
};
|
||||
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok(()));
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.stop = Some(stop_tx);
|
||||
self.thread = Some(thread);
|
||||
ready_rx.recv().map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if let Some(tx) = self.stop.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
|
||||
fn latency_frames(&self) -> u64 {
|
||||
self.latency.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalOut {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `effectcraft_ui_egui::control` for the methods.
|
||||
|
||||
mod audio_out;
|
||||
mod control_server;
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
|
|
@ -69,6 +70,7 @@ fn main() -> eframe::Result {
|
|||
}));
|
||||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.audio_device = Some(Box::new(audio_out::open));
|
||||
if let Some(port) = control_port {
|
||||
disable_app_nap();
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,682 @@
|
|||
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Delay, Flange & Chorus,
|
||||
//! High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
|
||||
//!
|
||||
//! They are ordinary effect instances on a layer (so their parameters animate and show in
|
||||
//! Effect Controls), pass video through unchanged, and are applied to the layer's audio by the
|
||||
//! composition mixdown (`effectcraft_render::audio`), which preview playback and export share.
|
||||
//!
|
||||
//! The mixdown renders in short blocks. To keep the result independent of block boundaries
|
||||
//! every effect is stateless across calls: the mixer feeds [`preroll`] seconds of the layer's
|
||||
//! audio before the block, runs the chain from a cleared state and keeps the tail.
|
||||
//!
|
||||
//! DSP is textbook: second-order sections use the public RBJ "Audio EQ Cookbook" formulas;
|
||||
//! Reverb is a Schroeder/Moorer network (parallel low-pass feedback combs into series
|
||||
//! all-passes, as in the published Freeverb description); chorus/flange/vibrato are modulated
|
||||
//! fractional delay lines.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
|
||||
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Audio", params, render: passthrough, gpu: false, float: true }
|
||||
}
|
||||
|
||||
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
fn pct(default: f64) -> (Value, ParamUi) {
|
||||
(num(default), slider(0.0, 100.0, 0.0, 100.0, 2))
|
||||
}
|
||||
|
||||
fn pp(id: &'static str, name: &'static str, v: (Value, ParamUi)) -> crate::ParamSpec {
|
||||
p(id, name, v.0, v.1)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
const BANDS: [[&str; 8]; 3] = [
|
||||
["band1Enable", "Band 1 Enable", "band1Frequency", "Band 1 Frequency", "band1Bandwidth", "Band 1 Bandwidth", "band1BoostCut", "Band 1 Boost/Cut"],
|
||||
["band2Enable", "Band 2 Enable", "band2Frequency", "Band 2 Frequency", "band2Bandwidth", "Band 2 Bandwidth", "band2BoostCut", "Band 2 Boost/Cut"],
|
||||
["band3Enable", "Band 3 Enable", "band3Frequency", "Band 3 Frequency", "band3Bandwidth", "Band 3 Bandwidth", "band3BoostCut", "Band 3 Boost/Cut"],
|
||||
];
|
||||
let mut eq = vec![];
|
||||
for (b, (f, on)) in BANDS.iter().zip([(200.0, true), (1000.0, false), (5000.0, false)]) {
|
||||
eq.push(p(b[0], b[1], Value::Bool(on), ParamUi::Checkbox));
|
||||
eq.push(p(b[2], b[3], num(f), slider(20.0, 20000.0, 20.0, 20000.0, 1)));
|
||||
eq.push(p(b[4], b[5], num(30.0), slider(1.0, 100.0, 1.0, 100.0, 1)));
|
||||
eq.push(p(b[6], b[7], num(0.0), slider(-24.0, 24.0, -24.0, 24.0, 2)));
|
||||
}
|
||||
vec![
|
||||
spec("ec.audio.backwards", "Backwards", vec![p("swapChannels", "Swap Channels", Value::Bool(false), ParamUi::Checkbox)]),
|
||||
spec(
|
||||
"ec.audio.basstreble",
|
||||
"Bass & Treble",
|
||||
vec![
|
||||
p("bass", "Bass", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||||
p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.delay",
|
||||
"Delay",
|
||||
vec![
|
||||
p("delayTime", "Delay Time (milliseconds)", num(500.0), slider(1.0, 10000.0, 1.0, 2000.0, 2)),
|
||||
pp("delayAmount", "Delay Amount", pct(50.0)),
|
||||
pp("feedback", "Feedback", pct(50.0)),
|
||||
pp("dryOut", "Dry Out", pct(75.0)),
|
||||
pp("wetOut", "Wet Out", pct(75.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.flangechorus",
|
||||
"Flange & Chorus",
|
||||
vec![
|
||||
p("voiceSeparationTime", "Voice Separation Time (ms)", num(3.0), slider(0.0, 100.0, 0.0, 50.0, 2)),
|
||||
p("voices", "Voices", num(1.0), slider(1.0, 8.0, 1.0, 8.0, 0)),
|
||||
p("modulationRate", "Modulation Rate", num(0.4), slider(0.0, 10.0, 0.0, 5.0, 2)),
|
||||
pp("modulationDepth", "Modulation Depth", pct(50.0)),
|
||||
p("voicePhaseChange", "Voice Phase Change", num(0.0), ParamUi::Angle),
|
||||
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("stereoVoices", "Stereo Voices", Value::Bool(false), ParamUi::Checkbox),
|
||||
pp("dryOut", "Dry Out", pct(50.0)),
|
||||
pp("wetOut", "Wet Out", pct(50.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.highlowpass",
|
||||
"High-Low Pass",
|
||||
vec![
|
||||
p("filterOptions", "Filter Options", Value::Enum(0), popup(&["High Pass", "Low Pass"])),
|
||||
p("cutoffFrequency", "Cutoff Frequency", num(2000.0), slider(20.0, 20000.0, 20.0, 20000.0, 1)),
|
||||
pp("dryOut", "Dry Out", pct(0.0)),
|
||||
pp("wetOut", "Wet Out", pct(100.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.modulator",
|
||||
"Modulator",
|
||||
vec![
|
||||
p("modulationType", "Modulation Type", Value::Enum(0), popup(&["Sine", "Triangle"])),
|
||||
p("modulationRate", "Modulation Rate", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
|
||||
pp("modulationDepth", "Modulation Depth", pct(5.0)),
|
||||
pp("amplitudeModulation", "Amplitude Modulation", pct(0.0)),
|
||||
],
|
||||
),
|
||||
spec("ec.audio.parametriceq", "Parametric EQ", eq),
|
||||
spec(
|
||||
"ec.audio.reverb",
|
||||
"Reverb",
|
||||
vec![
|
||||
p("reverbTime", "Reverb Time (milliseconds)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
|
||||
pp("diffusion", "Diffusion", pct(75.0)),
|
||||
pp("decay", "Decay", pct(25.0)),
|
||||
pp("brightness", "Brightness", pct(10.0)),
|
||||
pp("dryOut", "Dry Out", pct(90.0)),
|
||||
pp("wetOut", "Wet Out", pct(10.0)),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.stereomixer",
|
||||
"Stereo Mixer",
|
||||
vec![
|
||||
p("leftLevel", "Left Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
|
||||
p("rightLevel", "Right Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
|
||||
p("leftPan", "Left Pan", num(-100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||||
p("rightPan", "Right Pan", num(100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||||
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
),
|
||||
spec(
|
||||
"ec.audio.tone",
|
||||
"Tone",
|
||||
vec![
|
||||
p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Sine", "Triangle", "Saw", "Square"])),
|
||||
p("frequency1", "Frequency 1", num(440.0), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||||
p("frequency2", "Frequency 2", num(493.88), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||||
p("frequency3", "Frequency 3", num(587.33), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||||
p("frequency4", "Frequency 4", num(659.26), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||||
p("frequency5", "Frequency 5", num(783.99), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||||
pp("level", "Level", pct(20.0)),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Is `id` an audio effect (applied by the mixdown rather than the compositor)?
|
||||
pub fn is_audio_effect(id: &str) -> bool {
|
||||
id.starts_with("ec.audio.")
|
||||
}
|
||||
|
||||
/// Does the effect produce sound on its own (so a layer without audio becomes audible)?
|
||||
pub fn generates_audio(id: &str) -> bool {
|
||||
id == "ec.audio.tone"
|
||||
}
|
||||
|
||||
/// Does the effect play the layer's audio backwards (a time mapping the mixer applies)?
|
||||
pub fn reverses(id: &str) -> bool {
|
||||
id == "ec.audio.backwards"
|
||||
}
|
||||
|
||||
/// Seconds of input history the effect needs before a block to reach the state it would have
|
||||
/// had when processing continuously.
|
||||
pub fn preroll(id: &str, params: &Params) -> f64 {
|
||||
match id {
|
||||
"ec.audio.basstreble" | "ec.audio.highlowpass" | "ec.audio.parametriceq" => 0.05,
|
||||
"ec.audio.flangechorus" | "ec.audio.modulator" => 0.06,
|
||||
"ec.audio.delay" => {
|
||||
let d = params.f("delayTime").max(1.0) / 1000.0;
|
||||
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99);
|
||||
let taps = if fb > 1e-3 { (1e-3f64.ln() / fb.ln()).ceil() + 1.0 } else { 1.0 };
|
||||
(d * taps).min(10.0)
|
||||
}
|
||||
"ec.audio.reverb" => {
|
||||
let (fb, _, _, pre) = reverb_params(params);
|
||||
// T60 of the longest comb (~37 ms) with loop gain fb, plus the pre-delay.
|
||||
let t60 = if fb > 1e-3 { -3.0 * 0.0367 / fb.log10() } else { 0.05 };
|
||||
(t60 + pre).min(4.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run audio effect `id` over interleaved stereo `buf` (L R L R …) at `rate` Hz, from a cleared
|
||||
/// state. `start` is the layer time (seconds) of the first sample (oscillator phase).
|
||||
pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64) {
|
||||
if rate == 0 || buf.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let sr = rate as f64;
|
||||
match id {
|
||||
"ec.audio.backwards" => {
|
||||
if params.b("swapChannels") {
|
||||
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
|
||||
}
|
||||
}
|
||||
"ec.audio.basstreble" => {
|
||||
let bass = params.f("bass") / 100.0 * 12.0;
|
||||
let treble = params.f("treble") / 100.0 * 12.0;
|
||||
if bass != 0.0 {
|
||||
run_biquad(buf, Biquad::low_shelf(sr, 200.0, bass));
|
||||
}
|
||||
if treble != 0.0 {
|
||||
run_biquad(buf, Biquad::high_shelf(sr, 4000.0, treble));
|
||||
}
|
||||
}
|
||||
"ec.audio.highlowpass" => {
|
||||
let fc = params.f("cutoffFrequency").clamp(1.0, sr * 0.49);
|
||||
let q = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let bq = if params.e("filterOptions") == 1 { Biquad::low_pass(sr, fc, q) } else { Biquad::high_pass(sr, fc, q) };
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
let orig = buf.to_vec();
|
||||
run_biquad(buf, bq);
|
||||
mix_dry_wet(buf, &orig, dry, wet);
|
||||
}
|
||||
"ec.audio.parametriceq" => {
|
||||
for n in 1..=3 {
|
||||
if !params.b(&format!("band{n}Enable")) {
|
||||
continue;
|
||||
}
|
||||
let g = params.f(&format!("band{n}BoostCut"));
|
||||
if g == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let f = params.f(&format!("band{n}Frequency")).clamp(1.0, sr * 0.49);
|
||||
let bw = (params.f(&format!("band{n}Bandwidth")) / 100.0 * 3.0).max(0.05);
|
||||
run_biquad(buf, Biquad::peaking(sr, f, bw, g));
|
||||
}
|
||||
}
|
||||
"ec.audio.delay" => delay(params, buf, sr),
|
||||
"ec.audio.flangechorus" => flange_chorus(params, buf, sr, start),
|
||||
"ec.audio.modulator" => modulator(params, buf, sr, start),
|
||||
"ec.audio.reverb" => reverb(params, buf, sr),
|
||||
"ec.audio.stereomixer" => stereo_mixer(params, buf),
|
||||
"ec.audio.tone" => tone(params, buf, sr, start),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_dry_wet(buf: &mut [f32], dry_src: &[f32], dry: f32, wet: f32) {
|
||||
for (o, d) in buf.iter_mut().zip(dry_src) {
|
||||
*o = *o * wet + *d * dry;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- biquads (RBJ cookbook)
|
||||
|
||||
/// Normalised second-order section coefficients (a0 = 1).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Biquad {
|
||||
pub b: [f64; 3],
|
||||
pub a: [f64; 2],
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
fn norm(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Biquad {
|
||||
Biquad { b: [b0 / a0, b1 / a0, b2 / a0], a: [a1 / a0, a2 / a0] }
|
||||
}
|
||||
pub fn low_pass(sr: f64, f0: f64, q: f64) -> Biquad {
|
||||
let w = 2.0 * PI * f0 / sr;
|
||||
let (s, c) = w.sin_cos();
|
||||
let al = s / (2.0 * q);
|
||||
Self::norm((1.0 - c) / 2.0, 1.0 - c, (1.0 - c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
|
||||
}
|
||||
pub fn high_pass(sr: f64, f0: f64, q: f64) -> Biquad {
|
||||
let w = 2.0 * PI * f0 / sr;
|
||||
let (s, c) = w.sin_cos();
|
||||
let al = s / (2.0 * q);
|
||||
Self::norm((1.0 + c) / 2.0, -(1.0 + c), (1.0 + c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
|
||||
}
|
||||
/// Peaking EQ with bandwidth `bw` in octaves and gain in dB.
|
||||
pub fn peaking(sr: f64, f0: f64, bw: f64, db: f64) -> Biquad {
|
||||
let a = 10f64.powf(db / 40.0);
|
||||
let w = 2.0 * PI * f0 / sr;
|
||||
let (s, c) = w.sin_cos();
|
||||
let al = s * (2f64.ln() / 2.0 * bw * w / s).sinh();
|
||||
Self::norm(1.0 + al * a, -2.0 * c, 1.0 - al * a, 1.0 + al / a, -2.0 * c, 1.0 - al / a)
|
||||
}
|
||||
/// Shelf with slope S = 1.
|
||||
pub fn low_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
|
||||
let a = 10f64.powf(db / 40.0);
|
||||
let w = 2.0 * PI * f0 / sr;
|
||||
let (s, c) = w.sin_cos();
|
||||
let al = s / 2.0 * std::f64::consts::SQRT_2;
|
||||
let sq = 2.0 * a.sqrt() * al;
|
||||
Self::norm(
|
||||
a * ((a + 1.0) - (a - 1.0) * c + sq),
|
||||
2.0 * a * ((a - 1.0) - (a + 1.0) * c),
|
||||
a * ((a + 1.0) - (a - 1.0) * c - sq),
|
||||
(a + 1.0) + (a - 1.0) * c + sq,
|
||||
-2.0 * ((a - 1.0) + (a + 1.0) * c),
|
||||
(a + 1.0) + (a - 1.0) * c - sq,
|
||||
)
|
||||
}
|
||||
pub fn high_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
|
||||
let a = 10f64.powf(db / 40.0);
|
||||
let w = 2.0 * PI * f0 / sr;
|
||||
let (s, c) = w.sin_cos();
|
||||
let al = s / 2.0 * std::f64::consts::SQRT_2;
|
||||
let sq = 2.0 * a.sqrt() * al;
|
||||
Self::norm(
|
||||
a * ((a + 1.0) + (a - 1.0) * c + sq),
|
||||
-2.0 * a * ((a - 1.0) + (a + 1.0) * c),
|
||||
a * ((a + 1.0) + (a - 1.0) * c - sq),
|
||||
(a + 1.0) - (a - 1.0) * c + sq,
|
||||
2.0 * ((a - 1.0) - (a + 1.0) * c),
|
||||
(a + 1.0) - (a - 1.0) * c - sq,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct form I over both channels of an interleaved buffer.
|
||||
fn run_biquad(buf: &mut [f32], q: Biquad) {
|
||||
for ch in 0..2 {
|
||||
let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
|
||||
for i in (ch..buf.len()).step_by(2) {
|
||||
let x = buf[i] as f64;
|
||||
let y = q.b[0] * x + q.b[1] * x1 + q.b[2] * x2 - q.a[0] * y1 - q.a[1] * y2;
|
||||
x2 = x1;
|
||||
x1 = x;
|
||||
y2 = y1;
|
||||
y1 = y;
|
||||
buf[i] = y as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delay lines
|
||||
|
||||
/// Linear-interpolated read `d` samples (fractional) behind position `i` of channel `ch`.
|
||||
fn read_frac(src: &[f32], i: usize, ch: usize, d: f64) -> f32 {
|
||||
let pos = i as f64 - d;
|
||||
if pos < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let p0 = pos.floor() as usize;
|
||||
let f = (pos - p0 as f64) as f32;
|
||||
let a = src[p0 * 2 + ch];
|
||||
let b = if p0 < i { src[(p0 + 1) * 2 + ch] } else { a };
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
fn delay(params: &Params, buf: &mut [f32], sr: f64) {
|
||||
let d = ((params.f("delayTime").max(0.0) / 1000.0 * sr).round() as usize).max(1);
|
||||
let amount = (params.f("delayAmount") / 100.0) as f32;
|
||||
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99) as f32;
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
let n = buf.len() / 2;
|
||||
// Echo line: e[i] = x[i-d] + fb * e[i-d]; out = dry*x + wet*amount*e.
|
||||
let mut e = vec![0.0f32; buf.len()];
|
||||
for i in d..n {
|
||||
for ch in 0..2 {
|
||||
e[i * 2 + ch] = buf[(i - d) * 2 + ch] + fb * e[(i - d) * 2 + ch];
|
||||
}
|
||||
}
|
||||
for (o, v) in buf.iter_mut().zip(&e) {
|
||||
*o = *o * dry + v * wet * amount;
|
||||
}
|
||||
}
|
||||
|
||||
fn lfo(kind: u32, phase: f64) -> f64 {
|
||||
let ph = phase.rem_euclid(1.0);
|
||||
match kind {
|
||||
1 => 1.0 - 4.0 * (ph - 0.5).abs(), // triangle, −1..1
|
||||
_ => (2.0 * PI * ph).sin(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flange_chorus(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||||
let sep = params.f("voiceSeparationTime").max(0.0) / 1000.0 * sr;
|
||||
let voices = params.f("voices").round().clamp(1.0, 8.0) as usize;
|
||||
let rate = params.f("modulationRate").max(0.0);
|
||||
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0);
|
||||
let vphase = params.f("voicePhaseChange") / 360.0;
|
||||
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
|
||||
let stereo = params.b("stereoVoices");
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
let src = buf.to_vec();
|
||||
let n = buf.len() / 2;
|
||||
for i in 0..n {
|
||||
let t = start + i as f64 / sr;
|
||||
let mut acc = [0.0f32; 2];
|
||||
let mut cnt = [0usize; 2];
|
||||
for v in 0..voices {
|
||||
let base = sep * (v + 1) as f64;
|
||||
let d = (base * (1.0 + depth * lfo(0, rate * t + vphase * v as f64))).max(0.0);
|
||||
for ch in 0..2 {
|
||||
if stereo && voices > 1 && v % 2 != ch {
|
||||
continue;
|
||||
}
|
||||
acc[ch] += read_frac(&src, i, ch, d);
|
||||
cnt[ch] += 1;
|
||||
}
|
||||
}
|
||||
for ch in 0..2 {
|
||||
let w = if cnt[ch] > 0 { acc[ch] / cnt[ch] as f32 } else { 0.0 };
|
||||
buf[i * 2 + ch] = src[i * 2 + ch] * dry + inv * w * wet;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn modulator(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||||
let kind = params.e("modulationType");
|
||||
let rate = params.f("modulationRate").max(0.0);
|
||||
// Depth: vibrato as a modulated delay of up to 5 ms; amplitude: tremolo.
|
||||
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0) * 0.005 * sr;
|
||||
let am = (params.f("amplitudeModulation") / 100.0).clamp(0.0, 1.0);
|
||||
let src = buf.to_vec();
|
||||
let n = buf.len() / 2;
|
||||
for i in 0..n {
|
||||
let t = start + i as f64 / sr;
|
||||
let l = lfo(kind, rate * t);
|
||||
let d = depth * (1.0 + l) * 0.5;
|
||||
let g = (1.0 - am * (1.0 - l) * 0.5) as f32;
|
||||
for ch in 0..2 {
|
||||
let x = if depth > 0.0 { read_frac(&src, i, ch, d) } else { src[i * 2 + ch] };
|
||||
buf[i * 2 + ch] = x * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// (comb feedback, damping, all-pass gain, pre-delay seconds)
|
||||
fn reverb_params(params: &Params) -> (f64, f64, f64, f64) {
|
||||
let decay = (params.f("decay") / 100.0).clamp(0.0, 1.0);
|
||||
let fb = 0.7 + decay * 0.28;
|
||||
let damp = (1.0 - params.f("brightness") / 100.0).clamp(0.0, 1.0) * 0.6;
|
||||
let ap = 0.3 + 0.4 * (params.f("diffusion") / 100.0).clamp(0.0, 1.0);
|
||||
let pre = params.f("reverbTime").max(0.0) / 1000.0;
|
||||
(fb, damp, ap, pre)
|
||||
}
|
||||
|
||||
fn reverb(params: &Params, buf: &mut [f32], sr: f64) {
|
||||
const COMBS: [usize; 8] = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
|
||||
const ALLPASS: [usize; 4] = [556, 441, 341, 225];
|
||||
const SPREAD: usize = 23;
|
||||
let (fb, damp, apg, pre) = reverb_params(params);
|
||||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||||
let k = sr / 44100.0;
|
||||
let pre_n = (pre * sr).round() as usize;
|
||||
let n = buf.len() / 2;
|
||||
let src = buf.to_vec();
|
||||
for ch in 0..2 {
|
||||
let spread = if ch == 1 { SPREAD } else { 0 };
|
||||
let mut combs: Vec<(Vec<f64>, usize, f64)> = COMBS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0, 0.0)).collect();
|
||||
let mut aps: Vec<(Vec<f64>, usize)> = ALLPASS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0)).collect();
|
||||
for i in 0..n {
|
||||
let x = if i >= pre_n { (src[(i - pre_n) * 2] as f64 + src[(i - pre_n) * 2 + 1] as f64) * 0.5 * 0.03 } else { 0.0 };
|
||||
let mut out = 0.0;
|
||||
for (line, pos, filt) in combs.iter_mut() {
|
||||
let y = line[*pos];
|
||||
*filt = y * (1.0 - damp) + *filt * damp;
|
||||
line[*pos] = x + *filt * fb;
|
||||
*pos = (*pos + 1) % line.len();
|
||||
out += y;
|
||||
}
|
||||
for (line, pos) in aps.iter_mut() {
|
||||
let b = line[*pos];
|
||||
let y = -out + b;
|
||||
line[*pos] = out + b * apg;
|
||||
*pos = (*pos + 1) % line.len();
|
||||
out = y;
|
||||
}
|
||||
buf[i * 2 + ch] = src[i * 2 + ch] * dry + out as f32 * wet * 3.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stereo_mixer(params: &Params, buf: &mut [f32]) {
|
||||
let ll = (params.f("leftLevel") / 100.0) as f32;
|
||||
let rl = (params.f("rightLevel") / 100.0) as f32;
|
||||
// Linear pan law: −100 = all left, 0 = both at full, +100 = all right.
|
||||
let pan = |p: f64| -> (f32, f32) {
|
||||
let p = (p / 100.0).clamp(-1.0, 1.0);
|
||||
((1.0 - p).min(1.0) as f32, (1.0 + p).min(1.0) as f32)
|
||||
};
|
||||
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
|
||||
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
|
||||
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let (l, r) = (f[0] * ll, f[1] * rl);
|
||||
f[0] = (l * l_to_l + r * r_to_l) * inv;
|
||||
f[1] = (l * l_to_r + r * r_to_r) * inv;
|
||||
}
|
||||
}
|
||||
|
||||
fn tone(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||||
let kind = params.e("waveformOptions");
|
||||
let level = (params.f("level") / 100.0) as f32;
|
||||
let freqs: Vec<f64> = (1..=5).map(|i| params.f(&format!("frequency{i}"))).filter(|f| *f > 0.0).collect();
|
||||
let n = buf.len() / 2;
|
||||
for i in 0..n {
|
||||
let t = start + i as f64 / sr;
|
||||
let mut v = 0.0f64;
|
||||
for f in &freqs {
|
||||
let ph = (f * t).rem_euclid(1.0);
|
||||
v += match kind {
|
||||
1 => lfo(1, ph - 0.25),
|
||||
2 => 2.0 * ph - 1.0,
|
||||
3 => {
|
||||
if ph < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
_ => (2.0 * PI * ph).sin(),
|
||||
};
|
||||
}
|
||||
let v = v as f32 * level;
|
||||
buf[i * 2] += v;
|
||||
buf[i * 2 + 1] += v;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn defaults(id: &str) -> Params {
|
||||
let s = crate::find(id).unwrap();
|
||||
Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }
|
||||
}
|
||||
|
||||
fn sine(freq: f64, sr: f64, n: usize) -> Vec<f32> {
|
||||
(0..n)
|
||||
.flat_map(|i| {
|
||||
let v = (2.0 * PI * freq * i as f64 / sr).sin() as f32;
|
||||
[v, v]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(b: &[f32], from: usize) -> f32 {
|
||||
let s: f32 = b[from * 2..].iter().map(|v| v * v).sum();
|
||||
(s / (b.len() - from * 2) as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_as_audio() {
|
||||
for id in [
|
||||
"ec.audio.backwards",
|
||||
"ec.audio.basstreble",
|
||||
"ec.audio.delay",
|
||||
"ec.audio.flangechorus",
|
||||
"ec.audio.highlowpass",
|
||||
"ec.audio.modulator",
|
||||
"ec.audio.parametriceq",
|
||||
"ec.audio.reverb",
|
||||
"ec.audio.stereomixer",
|
||||
"ec.audio.tone",
|
||||
] {
|
||||
let s = crate::find(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(s.category, "Audio");
|
||||
assert!(is_audio_effect(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_low_pass_attenuates() {
|
||||
let sr = 48000.0;
|
||||
let mut p = defaults("ec.audio.highlowpass");
|
||||
p.values.insert("filterOptions".into(), Value::Enum(1));
|
||||
p.values.insert("cutoffFrequency".into(), num(500.0));
|
||||
let mut hi = sine(8000.0, sr, 4800);
|
||||
process("ec.audio.highlowpass", &p, &mut hi, 48000, 0.0);
|
||||
let mut lo = sine(100.0, sr, 4800);
|
||||
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
|
||||
assert!(rms(&hi, 1000) < 0.01, "{}", rms(&hi, 1000));
|
||||
assert!(rms(&lo, 1000) > 0.6, "{}", rms(&lo, 1000));
|
||||
// High pass does the opposite.
|
||||
p.values.insert("filterOptions".into(), Value::Enum(0));
|
||||
let mut lo = sine(50.0, sr, 9600);
|
||||
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
|
||||
assert!(rms(&lo, 2000) < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_offsets_impulse() {
|
||||
let mut p = defaults("ec.audio.delay");
|
||||
p.values.insert("delayTime".into(), num(10.0));
|
||||
p.values.insert("feedback".into(), num(0.0));
|
||||
let mut b = vec![0.0f32; 2000];
|
||||
b[0] = 1.0;
|
||||
b[1] = 1.0;
|
||||
process("ec.audio.delay", &p, &mut b, 10000, 0.0);
|
||||
// dry 75 %, then the echo at 100 samples: wet 75 % × amount 50 %.
|
||||
assert!((b[0] - 0.75).abs() < 1e-6);
|
||||
assert!((b[200] - 0.375).abs() < 1e-6 && (b[201] - 0.375).abs() < 1e-6);
|
||||
assert!(b[2..200].iter().all(|v| *v == 0.0));
|
||||
assert!(preroll("ec.audio.delay", &p) >= 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_mixer_pans() {
|
||||
let mut p = defaults("ec.audio.stereomixer");
|
||||
let mut b = vec![1.0, 0.0, 1.0, 0.0];
|
||||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||||
assert_eq!(b, vec![1.0, 0.0, 1.0, 0.0], "defaults are identity");
|
||||
p.values.insert("leftPan".into(), num(100.0));
|
||||
let mut b = vec![1.0, 0.0];
|
||||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||||
assert_eq!(b, vec![0.0, 1.0]);
|
||||
p.values.insert("leftPan".into(), num(0.0));
|
||||
p.values.insert("leftLevel".into(), num(50.0));
|
||||
let mut b = vec![1.0, 0.0];
|
||||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||||
assert_eq!(b, vec![0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_generates_frequency() {
|
||||
let mut p = defaults("ec.audio.tone");
|
||||
p.values.insert("frequency1".into(), num(1000.0));
|
||||
for i in 2..=5 {
|
||||
p.values.insert(format!("frequency{i}"), num(0.0));
|
||||
}
|
||||
let sr = 48000;
|
||||
let mut b = vec![0.0f32; 48000 * 2];
|
||||
process("ec.audio.tone", &p, &mut b, sr, 0.0);
|
||||
let left: Vec<f32> = b.iter().step_by(2).copied().collect();
|
||||
let crossings = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
|
||||
assert!((crossings as i64 - 1000).abs() <= 1, "{crossings}");
|
||||
let peak = left.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
assert!((peak - 0.2).abs() < 1e-3, "{peak}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bass_treble_eq_reverb_modulator_flange() {
|
||||
let sr = 48000.0;
|
||||
// Bass boost raises 60 Hz, treble cut lowers 10 kHz.
|
||||
let mut p = defaults("ec.audio.basstreble");
|
||||
p.values.insert("bass".into(), num(100.0));
|
||||
p.values.insert("treble".into(), num(-100.0));
|
||||
let mut lo = sine(60.0, sr, 9600);
|
||||
process("ec.audio.basstreble", &p, &mut lo, 48000, 0.0);
|
||||
assert!(rms(&lo, 4000) > 0.707 * 3.0, "{}", rms(&lo, 4000));
|
||||
let mut hi = sine(10000.0, sr, 9600);
|
||||
process("ec.audio.basstreble", &p, &mut hi, 48000, 0.0);
|
||||
assert!(rms(&hi, 4000) < 0.707 * 0.4, "{}", rms(&hi, 4000));
|
||||
// Parametric EQ boost at its centre.
|
||||
let mut p = defaults("ec.audio.parametriceq");
|
||||
p.values.insert("band1BoostCut".into(), num(12.0));
|
||||
p.values.insert("band1Frequency".into(), num(1000.0));
|
||||
let mut s = sine(1000.0, sr, 9600);
|
||||
process("ec.audio.parametriceq", &p, &mut s, 48000, 0.0);
|
||||
assert!((rms(&s, 4000) * std::f32::consts::SQRT_2 - 3.98).abs() < 0.2, "{}", rms(&s, 4000) * std::f32::consts::SQRT_2);
|
||||
// Reverb leaves a tail after an impulse.
|
||||
let p = defaults("ec.audio.reverb");
|
||||
let mut b = vec![0.0f32; 48000];
|
||||
b[0] = 1.0;
|
||||
b[1] = 1.0;
|
||||
process("ec.audio.reverb", &p, &mut b, 48000, 0.0);
|
||||
assert!(b[2 * 12000..].iter().any(|v| v.abs() > 1e-5));
|
||||
assert!(b.iter().all(|v| v.is_finite() && v.abs() < 2.0));
|
||||
// Modulator with amplitude modulation dips the level; flange keeps it bounded.
|
||||
let mut p = defaults("ec.audio.modulator");
|
||||
p.values.insert("amplitudeModulation".into(), num(100.0));
|
||||
let mut b = vec![1.0f32; 9600];
|
||||
process("ec.audio.modulator", &p, &mut b, 48000, 0.0);
|
||||
assert!(b.iter().any(|v| *v < 0.05) && b.iter().any(|v| *v > 0.95));
|
||||
let p = defaults("ec.audio.flangechorus");
|
||||
let mut b = sine(440.0, sr, 4800);
|
||||
process("ec.audio.flangechorus", &p, &mut b, 48000, 0.0);
|
||||
assert!(b.iter().all(|v| v.abs() <= 1.0 + 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backwards_swaps_channels() {
|
||||
let mut p = defaults("ec.audio.backwards");
|
||||
p.values.insert("swapChannels".into(), Value::Bool(true));
|
||||
let mut b = vec![1.0, 2.0];
|
||||
process("ec.audio.backwards", &p, &mut b, 48000, 0.0);
|
||||
assert_eq!(b, vec![2.0, 1.0]);
|
||||
assert!(reverses("ec.audio.backwards"));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
//! [`instantiate`]), so parameters animate, take expressions and are addressable like any other
|
||||
//! property.
|
||||
|
||||
pub mod audio_fx;
|
||||
mod blur2;
|
||||
mod channel;
|
||||
mod channel2;
|
||||
|
|
@ -33,6 +34,7 @@ mod sim2;
|
|||
mod stylize2;
|
||||
mod stylize3;
|
||||
mod textfx;
|
||||
mod time_fx;
|
||||
mod transition;
|
||||
mod transition2;
|
||||
pub mod util;
|
||||
|
|
@ -161,6 +163,18 @@ pub trait EffectHost: Sync {
|
|||
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
|
||||
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
|
||||
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>>;
|
||||
/// The effect's own layer at another **layer time** `layer_time` (seconds): its source with
|
||||
/// masks applied, followed by the first `effects` effects of its stack (0 = none, which is
|
||||
/// what After Effects' Time effects see). `None` when unavailable (no host, recursion
|
||||
/// limit, adjustment layers). Time effects use this to read neighbouring frames.
|
||||
fn self_at(&self, _layer_time: f64, _effects: usize) -> Option<Buf> {
|
||||
None
|
||||
}
|
||||
/// Layer `id` of the same composition at comp time `comp_time` (seconds), like
|
||||
/// [`EffectHost::layer`] but at another time.
|
||||
fn layer_at(&self, _id: u64, _comp_time: f64, _masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
|
||||
|
|
@ -173,6 +187,8 @@ pub struct EffectEnv<'a> {
|
|||
pub comp_time: f64,
|
||||
/// Composition frame rate (0 when unknown).
|
||||
pub frame_rate: f64,
|
||||
/// Index of the running effect in its layer's stack (for [`EffectHost::self_at`]).
|
||||
pub effect_index: usize,
|
||||
}
|
||||
|
||||
/// What an effect gets to render with.
|
||||
|
|
@ -268,6 +284,8 @@ pub fn registry() -> &'static [EffectSpec] {
|
|||
v.extend(noise2::specs());
|
||||
v.extend(color3::specs());
|
||||
v.extend(obsolete::specs());
|
||||
v.extend(time_fx::specs());
|
||||
v.extend(audio_fx::specs());
|
||||
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
|
||||
v
|
||||
})
|
||||
|
|
@ -319,6 +337,15 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.obsolete.lightning",
|
||||
"ec.text.timecode",
|
||||
"ec.text.numbers",
|
||||
// Time effects read neighbouring frames of the layer.
|
||||
"ec.time.echo",
|
||||
"ec.time.posterizetime",
|
||||
"ec.time.timedifference",
|
||||
"ec.time.timedisplacement",
|
||||
"ec.time.timewarp",
|
||||
"ec.time.ccforcemotionblur",
|
||||
"ec.time.ccwidetime",
|
||||
"ec.time.pixelmotionblur",
|
||||
];
|
||||
|
||||
/// See [`TIME_DEPENDENT`].
|
||||
|
|
|
|||
|
|
@ -0,0 +1,590 @@
|
|||
//! Time effects (Effect > Time): Echo, Posterize Time, Time Difference, Time Displacement,
|
||||
//! Timewarp, CC Force Motion Blur, CC Wide Time and Pixel Motion Blur.
|
||||
//!
|
||||
//! These read the layer at *other* layer times through [`EffectHost::self_at`] with zero
|
||||
//! preceding effects: like After Effects' Time effects they see the layer's source with its
|
||||
//! masks and ignore effects applied before them (precompose to include those). Without a host
|
||||
//! (tests, thumbnails) they pass their input through.
|
||||
//!
|
||||
//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by
|
||||
//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet.
|
||||
//!
|
||||
//! [`EffectHost::self_at`]: crate::EffectHost::self_at
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{fit_layer, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Time", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.time.echo",
|
||||
"Echo",
|
||||
vec![
|
||||
p("echoTime", "Echo Time (seconds)", num(-1.0 / 30.0), slider(-30.0, 30.0, -5.0, 5.0, 3)),
|
||||
p("numberOfEchoes", "Number Of Echoes", num(1.0), slider(0.0, 255.0, 0.0, 30.0, 0)),
|
||||
p("startingIntensity", "Starting Intensity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("decay", "Decay", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p(
|
||||
"echoOperator",
|
||||
"Echo Operator",
|
||||
Value::Enum(0),
|
||||
popup(&["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]),
|
||||
),
|
||||
],
|
||||
echo,
|
||||
),
|
||||
spec("ec.time.posterizetime", "Posterize Time", vec![p("frameRate", "Frame Rate", num(12.0), slider(0.1, 99.0, 1.0, 60.0, 1))], posterize_time),
|
||||
spec(
|
||||
"ec.time.timedifference",
|
||||
"Time Difference",
|
||||
vec![
|
||||
p("targetLayer", "Target", Value::Layer(None), ParamUi::Layer),
|
||||
p("timeOffset", "Time Offset (sec)", num(0.0), slider(-30.0, 30.0, -5.0, 5.0, 3)),
|
||||
p("contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("absoluteDifference", "Absolute Difference", Value::Bool(true), ParamUi::Checkbox),
|
||||
p(
|
||||
"alphaChannel",
|
||||
"Alpha Channel",
|
||||
Value::Enum(0),
|
||||
popup(&[
|
||||
"Original",
|
||||
"Target",
|
||||
"Blend",
|
||||
"Max",
|
||||
"Full On",
|
||||
"Lightness of Result",
|
||||
"Max of Result",
|
||||
"Alpha Difference",
|
||||
"Alpha Difference Only",
|
||||
]),
|
||||
),
|
||||
],
|
||||
time_difference,
|
||||
),
|
||||
spec(
|
||||
"ec.time.timedisplacement",
|
||||
"Time Displacement",
|
||||
vec![
|
||||
p("displacementMapLayer", "Time Displacement Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("maxDisplacementTime", "Max Displacement Time [sec]", num(1.0), slider(-30.0, 30.0, -5.0, 5.0, 2)),
|
||||
p("timeResolution", "Time Resolution [fps]", num(60.0), slider(1.0, 999.0, 1.0, 120.0, 1)),
|
||||
p("stretchMap", "If Layer Sizes Differ: Stretch Map to Fit", Value::Bool(true), ParamUi::Checkbox),
|
||||
],
|
||||
time_displacement,
|
||||
),
|
||||
spec(
|
||||
"ec.time.timewarp",
|
||||
"Timewarp",
|
||||
vec![
|
||||
p("method", "Method", Value::Enum(2), popup(&["Whole Frames", "Frame Mix", "Pixel Motion"])),
|
||||
p("adjustTimeBy", "Adjust Time By", Value::Enum(0), popup(&["Speed", "Source Frame"])),
|
||||
p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)),
|
||||
p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)),
|
||||
p("vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])),
|
||||
p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
|
||||
p("shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
|
||||
],
|
||||
timewarp,
|
||||
),
|
||||
spec(
|
||||
"ec.time.ccforcemotionblur",
|
||||
"CC Force Motion Blur",
|
||||
vec![
|
||||
p("motionBlurLevels", "Motion Blur Levels", num(8.0), slider(2.0, 64.0, 2.0, 32.0, 0)),
|
||||
p("overrideShutterAngle", "Override Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 3600.0, 0.0, 360.0, 1)),
|
||||
p("nativeMotionBlur", "Native Motion Blur", Value::Enum(0), popup(&["Off", "On"])),
|
||||
],
|
||||
force_motion_blur,
|
||||
),
|
||||
spec(
|
||||
"ec.time.ccwidetime",
|
||||
"CC Wide Time",
|
||||
vec![
|
||||
p("forwardSteps", "Forward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
|
||||
p("backwardSteps", "Backward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
|
||||
p("nativeMotionBlur", "Native Motion Blur", Value::Enum(0), popup(&["Off", "On"])),
|
||||
],
|
||||
wide_time,
|
||||
),
|
||||
spec(
|
||||
"ec.time.pixelmotionblur",
|
||||
"Pixel Motion Blur",
|
||||
vec![
|
||||
p("shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])),
|
||||
p("shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
|
||||
p("shutterSamples", "Shutter Samples", num(16.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
|
||||
p("vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
pixel_motion_blur,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame access
|
||||
|
||||
/// Frames of the layer at several layer times, resampled onto one common pixel grid.
|
||||
struct Frames {
|
||||
/// Template buffer (geometry of the output: offset/scale/size).
|
||||
grid: Buf,
|
||||
imgs: Vec<Image>,
|
||||
}
|
||||
|
||||
/// Fetch the layer (source + masks) at layer times `times`. `None` without a host.
|
||||
fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option<Frames> {
|
||||
let host = ctx.env.host?;
|
||||
// Dedupe times (sub-microsecond differences are the same frame).
|
||||
let mut uniq: Vec<i64> = times.iter().map(|t| (t * 1e6).round() as i64).collect();
|
||||
uniq.sort_unstable();
|
||||
uniq.dedup();
|
||||
let bufs: Vec<(i64, Option<Buf>)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, 0))).collect();
|
||||
let first = bufs.iter().find_map(|(_, b)| b.as_ref())?;
|
||||
let scale = first.scale;
|
||||
// Union of every frame's extent in layer pixels (offset = where layer (0,0) sits).
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for b in bufs.iter().filter_map(|(_, b)| b.as_ref()) {
|
||||
let k = scale / b.scale.max(1e-9);
|
||||
x0 = x0.min(-b.offset[0] * k);
|
||||
y0 = y0.min(-b.offset[1] * k);
|
||||
x1 = x1.max((b.img.width as f64 - b.offset[0]) * k);
|
||||
y1 = y1.max((b.img.height as f64 - b.offset[1]) * k);
|
||||
}
|
||||
let (x0, y0) = (x0.floor(), y0.floor());
|
||||
let w = ((x1.ceil() - x0) as u32).clamp(1, 16384);
|
||||
let h = ((y1.ceil() - y0) as u32).clamp(1, 16384);
|
||||
let grid = Buf { img: Image::new(w, h), offset: [-x0, -y0], scale };
|
||||
let place = |b: &Buf| -> Image {
|
||||
if (b.scale - scale).abs() < 1e-9 && b.img.width == w && b.img.height == h && (b.offset[0] + x0).abs() < 1e-6 && (b.offset[1] + y0).abs() < 1e-6 {
|
||||
return b.img.clone();
|
||||
}
|
||||
let k = b.scale / scale;
|
||||
let same_scale = (k - 1.0).abs() < 1e-9;
|
||||
let dx = b.offset[0] + x0;
|
||||
let dy = b.offset[1] + y0;
|
||||
let mut out = Image::new(w, h);
|
||||
let ow = w as usize;
|
||||
out.data.par_chunks_mut(ow).enumerate().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
*px = if same_scale && dx.fract() == 0.0 && dy.fract() == 0.0 {
|
||||
b.img.get(x as i64 + dx as i64, y as i64 + dy as i64)
|
||||
} else {
|
||||
let sx = (x as f64 + 0.5) * k + dx - 0.5;
|
||||
let sy = (y as f64 + 0.5) * k + dy - 0.5;
|
||||
b.img.sample_bilinear(sx + 0.5, sy + 0.5)
|
||||
};
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let placed: Vec<(i64, Image)> = bufs.iter().map(|(k, b)| (*k, b.as_ref().map(place).unwrap_or_else(|| Image::new(w, h)))).collect();
|
||||
let imgs = times
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let k = (t * 1e6).round() as i64;
|
||||
placed.iter().find(|(q, _)| *q == k).map(|(_, i)| i.clone()).unwrap_or_else(|| Image::new(w, h))
|
||||
})
|
||||
.collect();
|
||||
Some(Frames { grid, imgs })
|
||||
}
|
||||
|
||||
fn with_img(grid: Buf, img: Image) -> Buf {
|
||||
Buf { img, ..grid }
|
||||
}
|
||||
|
||||
/// Weighted average of frames (weights sum to 1 for a plain average).
|
||||
fn average(frames: &[Image], weights: &[f32]) -> Image {
|
||||
let w = frames[0].width;
|
||||
let h = frames[0].height;
|
||||
let mut out = Image::new(w, h);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let mut acc = [0.0f32; 4];
|
||||
for (f, k) in frames.iter().zip(weights) {
|
||||
let p = f.data[i];
|
||||
for c in 0..4 {
|
||||
acc[c] += p[c] * k;
|
||||
}
|
||||
}
|
||||
*o = acc;
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn mean_of(ctx: &EffectCtx, b: Buf, times: &[f64]) -> Buf {
|
||||
if times.is_empty() {
|
||||
return b;
|
||||
}
|
||||
let Some(fr) = fetch(ctx, times) else { return b };
|
||||
let k = 1.0 / times.len() as f32;
|
||||
let img = average(&fr.imgs, &vec![k; times.len()]);
|
||||
with_img(fr.grid, img)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Echo
|
||||
|
||||
fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize;
|
||||
let dt = ctx.params.f("echoTime");
|
||||
let start = ctx.params.f("startingIntensity").clamp(0.0, 1.0) as f32;
|
||||
let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32;
|
||||
let op = ctx.params.e("echoOperator");
|
||||
let times: Vec<f64> = (0..=n).map(|i| ctx.time + i as f64 * dt).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let weights: Vec<f32> = (0..=n).map(|i| start * decay.powi(i as i32)).collect();
|
||||
let count = (n + 1) as f32;
|
||||
let w = fr.grid.img.width;
|
||||
let h = fr.grid.img.height;
|
||||
let mut out = Image::new(w, h);
|
||||
let imgs = &fr.imgs;
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let mut acc: Px = match op {
|
||||
2 => [f32::MAX; 4],
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
for (f, &k) in imgs.iter().zip(&weights) {
|
||||
let p = f.data[i];
|
||||
let q = [p[0] * k, p[1] * k, p[2] * k, p[3] * k];
|
||||
match op {
|
||||
// Maximum / Minimum
|
||||
1 => (0..4).for_each(|c| acc[c] = acc[c].max(q[c])),
|
||||
2 => (0..4).for_each(|c| acc[c] = acc[c].min(q[c])),
|
||||
// Screen
|
||||
3 => (0..4).for_each(|c| acc[c] = acc[c] + q[c] - acc[c] * q[c]),
|
||||
// Composite In Back: each later echo goes behind what is there.
|
||||
4 => {
|
||||
let ia = 1.0 - acc[3];
|
||||
(0..4).for_each(|c| acc[c] += q[c] * ia);
|
||||
}
|
||||
// Composite In Front: each later echo goes on top.
|
||||
5 => {
|
||||
let ia = 1.0 - q[3];
|
||||
(0..4).for_each(|c| acc[c] = q[c] + acc[c] * ia);
|
||||
}
|
||||
// Blend (average) and Add
|
||||
_ => (0..4).for_each(|c| acc[c] += q[c]),
|
||||
}
|
||||
}
|
||||
if op == 6 {
|
||||
acc.iter_mut().for_each(|c| *c /= count);
|
||||
}
|
||||
if op == 2 && acc[0] == f32::MAX {
|
||||
acc = [0.0; 4];
|
||||
}
|
||||
acc[3] = acc[3].clamp(0.0, 1.0);
|
||||
for c in 0..3 {
|
||||
acc[c] = acc[c].max(0.0);
|
||||
}
|
||||
*o = acc;
|
||||
});
|
||||
with_img(fr.grid, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Posterize Time
|
||||
|
||||
/// The layer time Posterize Time holds at `t` for `rate` frames per second.
|
||||
pub fn posterized_time(t: f64, rate: f64) -> f64 {
|
||||
if rate <= 0.0 {
|
||||
return t;
|
||||
}
|
||||
((t * rate) + 1e-6).floor() / rate
|
||||
}
|
||||
|
||||
fn posterize_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let tt = posterized_time(ctx.time, ctx.params.f("frameRate"));
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
}
|
||||
match fetch(ctx, &[tt]) {
|
||||
Some(mut fr) => {
|
||||
let img = fr.imgs.pop().unwrap_or_default();
|
||||
with_img(fr.grid, img)
|
||||
}
|
||||
None => b,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Difference
|
||||
|
||||
fn time_difference(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let Some(host) = ctx.env.host else { return b };
|
||||
let off = ctx.params.f("timeOffset");
|
||||
let gain = 1.0 + ctx.params.f("contrast") as f32 / 25.0;
|
||||
let abs = ctx.params.b("absoluteDifference");
|
||||
let amode = ctx.params.e("alphaChannel");
|
||||
let target_id = ctx.params.get("targetLayer").and_then(Value::as_layer);
|
||||
let (cur, target) = match target_id.and_then(|id| host.layer_at(id, ctx.env.comp_time + off, false)) {
|
||||
Some(other) => {
|
||||
let Some(mut fr) = fetch(ctx, &[ctx.time]) else { return b };
|
||||
let cur = fr.imgs.pop().unwrap_or_default();
|
||||
let tgt = fit_layer(ctx, &with_img(fr.grid.clone(), cur.clone()), &other, true);
|
||||
(with_img(fr.grid, cur), tgt)
|
||||
}
|
||||
None => {
|
||||
let Some(mut fr) = fetch(ctx, &[ctx.time, ctx.time + off]) else { return b };
|
||||
let tgt = fr.imgs.pop().unwrap_or_default();
|
||||
let cur = fr.imgs.pop().unwrap_or_default();
|
||||
(with_img(fr.grid, cur), tgt)
|
||||
}
|
||||
};
|
||||
let mut out = cur.img.clone();
|
||||
out.data.par_iter_mut().zip(target.data.par_iter()).for_each(|(o, t)| {
|
||||
let (c, ca) = unpremul(*o);
|
||||
let (d, da) = unpremul(*t);
|
||||
let mut rgb = [0.0f32; 3];
|
||||
for i in 0..3 {
|
||||
let diff = c[i] - d[i];
|
||||
rgb[i] = if abs { (diff.abs() * gain).clamp(0.0, 1.0) } else { (0.5 + diff * 0.5 * gain).clamp(0.0, 1.0) };
|
||||
}
|
||||
let light = (rgb[0].max(rgb[1]).max(rgb[2]) + rgb[0].min(rgb[1]).min(rgb[2])) * 0.5;
|
||||
let a = match amode {
|
||||
1 => da,
|
||||
2 => (ca + da) * 0.5,
|
||||
3 => ca.max(da),
|
||||
4 => 1.0,
|
||||
5 => light,
|
||||
6 => rgb[0].max(rgb[1]).max(rgb[2]),
|
||||
7 => (ca - da).abs() * gain,
|
||||
8 => {
|
||||
let a = ((ca - da).abs() * gain).clamp(0.0, 1.0);
|
||||
rgb = [1.0; 3];
|
||||
a
|
||||
}
|
||||
_ => ca,
|
||||
}
|
||||
.clamp(0.0, 1.0);
|
||||
*o = [rgb[0] * a, rgb[1] * a, rgb[2] * a, a];
|
||||
});
|
||||
Buf { img: out, ..cur }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Time Displacement
|
||||
|
||||
fn time_displacement(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
}
|
||||
let max = ctx.params.f("maxDisplacementTime");
|
||||
let res = ctx.params.f("timeResolution").clamp(1.0, 999.0);
|
||||
let stretch = ctx.params.b("stretchMap");
|
||||
let Some(mut cur) = fetch(ctx, &[ctx.time]) else { return b };
|
||||
let cur_img = cur.imgs.pop().unwrap_or_default();
|
||||
let grid = with_img(cur.grid, cur_img);
|
||||
let map = match ctx.layer_param("displacementMapLayer", true) {
|
||||
Some(o) => fit_layer(ctx, &grid, &o, stretch),
|
||||
None => grid.img.clone(),
|
||||
};
|
||||
// Per pixel: luminance 0..1 → offset −max..+max, quantised to the time resolution. Keep at
|
||||
// most 64 distinct sample times (coarser steps for huge ranges).
|
||||
let span = 2.0 * max.abs() * res;
|
||||
let step = if span > 63.0 { 2.0 * max.abs() / 63.0 } else { 1.0 / res };
|
||||
let idx: Vec<i32> = map
|
||||
.data
|
||||
.par_iter()
|
||||
.map(|p| {
|
||||
let (c, a) = unpremul(*p);
|
||||
let l = if a > 0.0 { effectcraft_color::luminance(c[0], c[1], c[2]) } else { 0.5 };
|
||||
let off = (l as f64 - 0.5) * 2.0 * max;
|
||||
(off / step).round() as i32
|
||||
})
|
||||
.collect();
|
||||
let mut keys: Vec<i32> = idx.clone();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
if keys == [0] {
|
||||
return grid;
|
||||
}
|
||||
let times: Vec<f64> = keys.iter().map(|k| ctx.time + *k as f64 * step).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return grid };
|
||||
// Frames come back on the union grid; map the current grid into it.
|
||||
let dx = (fr.grid.offset[0] - grid.offset[0]).round() as i64;
|
||||
let dy = (fr.grid.offset[1] - grid.offset[1]).round() as i64;
|
||||
let w = grid.img.width as usize;
|
||||
let mut out = grid.img.clone();
|
||||
out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let k = idx[y * w + x];
|
||||
let fi = keys.binary_search(&k).unwrap_or(0);
|
||||
*o = fr.imgs[fi].get(x as i64 + dx, y as i64 + dy);
|
||||
}
|
||||
});
|
||||
with_img(grid, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Timewarp
|
||||
|
||||
/// Source layer time Timewarp shows at layer time `t`.
|
||||
pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 {
|
||||
if by_frame { source_frame / fps } else { t * speed / 100.0 }
|
||||
}
|
||||
|
||||
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
}
|
||||
let fps = ctx.fps();
|
||||
let by_frame = ctx.params.e("adjustTimeBy") == 1;
|
||||
let speed = ctx.params.f("speed");
|
||||
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
|
||||
let method = ctx.params.e("method");
|
||||
// Sample times (with weights) for one instant of source time.
|
||||
let instant = |s: f64| -> Vec<(f64, f32)> {
|
||||
let f = s * fps;
|
||||
let f0 = (f + 1e-6).floor();
|
||||
let frac = (f - f0) as f32;
|
||||
if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] }
|
||||
};
|
||||
let mut samples: Vec<(f64, f32)> = Vec::new();
|
||||
if ctx.params.b("enableMotionBlur") {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let rate = if by_frame { 1.0 } else { speed / 100.0 };
|
||||
let span = angle / 360.0 * rate / fps;
|
||||
for i in 0..n {
|
||||
let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32)));
|
||||
}
|
||||
} else {
|
||||
samples = instant(src);
|
||||
}
|
||||
let times: Vec<f64> = samples.iter().map(|s| s.0).collect();
|
||||
let weights: Vec<f32> = samples.iter().map(|s| s.1).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let img = average(&fr.imgs, &weights);
|
||||
with_img(fr.grid, img)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
|
||||
|
||||
fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize;
|
||||
let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64;
|
||||
let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64;
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let times: Vec<f64> = (-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
|
||||
/// A layer whose every pixel at layer time `t` is grey `t` (clamped), alpha 1, on a 6×4 grid.
|
||||
struct Clock {
|
||||
static_layer: bool,
|
||||
}
|
||||
impl EffectHost for Clock {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, effects: usize) -> Option<Buf> {
|
||||
assert_eq!(effects, 0);
|
||||
let v = if self.static_layer { 0.4 } else { t as f32 };
|
||||
Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
fn run(id: &str, vals: &[(&str, Value)], t: f64, host: &Clock) -> Buf {
|
||||
let env = EffectEnv { host: Some(host), frame_rate: 10.0, comp_time: t, ..Default::default() };
|
||||
run_fx(id, vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posterize_time_holds_frames() {
|
||||
let h = Clock { static_layer: false };
|
||||
for (t, held) in [(0.0, 0.0), (0.1, 0.0), (0.24, 0.0), (0.25, 0.25), (0.49, 0.25), (0.5, 0.5)] {
|
||||
let out = run("ec.time.posterizetime", &[("frameRate", num(4.0))], t, &h);
|
||||
assert!((out.img.data[0][0] - held).abs() < 1e-5, "t={t}: {}", out.img.data[0][0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_blends_known_frames_with_decay() {
|
||||
let h = Clock { static_layer: false };
|
||||
let vals = [("echoTime", num(-0.1)), ("numberOfEchoes", num(2.0)), ("startingIntensity", num(1.0)), ("decay", num(0.5))];
|
||||
let out = run("ec.time.echo", &vals, 0.8, &h);
|
||||
// Add: f(0.8) + 0.5 f(0.7) + 0.25 f(0.6)
|
||||
let want = 0.8 + 0.5 * 0.7 + 0.25 * 0.6;
|
||||
assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
assert_eq!(out.img.data[0][3], 1.0);
|
||||
// Maximum picks the brightest weighted frame; Blend averages.
|
||||
let mut v = vals.to_vec();
|
||||
v.push(("echoOperator", Value::Enum(1)));
|
||||
let out = run("ec.time.echo", &v, 0.8, &h);
|
||||
assert!((out.img.data[0][0] - 0.8).abs() < 1e-5);
|
||||
v.pop();
|
||||
v.push(("echoOperator", Value::Enum(6)));
|
||||
let out = run("ec.time.echo", &v, 0.8, &h);
|
||||
assert!((out.img.data[0][0] - want as f32 / 3.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_difference_of_static_layer_is_black() {
|
||||
let h = Clock { static_layer: true };
|
||||
let out = run("ec.time.timedifference", &[("timeOffset", num(-0.5))], 1.0, &h);
|
||||
assert!(out.img.data.iter().all(|p| p[0] == 0.0 && p[1] == 0.0 && p[2] == 0.0 && p[3] == 1.0));
|
||||
// A changing layer is not black: |1.0 - 0.5| = 0.5.
|
||||
let h = Clock { static_layer: false };
|
||||
let out = run("ec.time.timedifference", &[("timeOffset", num(-0.5))], 1.0, &h);
|
||||
assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timewarp_and_wide_time() {
|
||||
let h = Clock { static_layer: false };
|
||||
// Speed 50 % at t = 1 s shows source 0.5 s (frame-aligned at 10 fps).
|
||||
let out = run("ec.time.timewarp", &[("method", Value::Enum(0))], 1.0, &h);
|
||||
assert!((out.img.data[0][0] - 0.5).abs() < 1e-5);
|
||||
// Frame Mix between 0.5 and 0.6 at source 0.55.
|
||||
let out = run("ec.time.timewarp", &[("method", Value::Enum(1))], 1.1, &h);
|
||||
assert!((out.img.data[0][0] - 0.55).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
// Source Frame mode: frame 3 at 10 fps.
|
||||
let out = run("ec.time.timewarp", &[("method", Value::Enum(0)), ("adjustTimeBy", Value::Enum(1)), ("sourceFrame", num(3.0))], 2.0, &h);
|
||||
assert!((out.img.data[0][0] - 0.3).abs() < 1e-5);
|
||||
// Wide Time is symmetric around t: mean of t-0.3..t+0.3 = t.
|
||||
let out = run("ec.time.ccwidetime", &[], 1.0, &h);
|
||||
assert!((out.img.data[0][0] - 1.0).abs() < 1e-4);
|
||||
// Pixel Motion Blur is centred too.
|
||||
let out = run("ec.time.pixelmotionblur", &[], 1.0, &h);
|
||||
assert!((out.img.data[0][0] - 1.0).abs() < 1e-4);
|
||||
// Force Motion Blur looks forward over the shutter.
|
||||
let out = run("ec.time.ccforcemotionblur", &[], 1.0, &h);
|
||||
assert!(out.img.data[0][0] > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_displacement_uses_luma_as_offset() {
|
||||
// Static map from a bright self frame: luminance 1.0 → +max seconds.
|
||||
let h = Clock { static_layer: true };
|
||||
let out = run("ec.time.timedisplacement", &[], 0.0, &h);
|
||||
assert!((out.img.data[0][0] - 0.4).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,3 +24,4 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
|
||||
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
|
||||
cmd!("keys.selectAll", "Select All Keyframes", ["Edit"], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
|
||||
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
|
||||
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
|
||||
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
|
||||
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
//! Animation menu: animation presets (EffectCraft's own JSON format), Add Keyframe, Keyframe
|
||||
//! Assistant (Exponential Scale), text animators/selectors, and the
|
||||
//! Reveal Properties commands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
|
||||
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if selected_leaf_props(s).is_empty() && selected_effects(s).is_empty() { Err("select properties or effects first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_text_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.props.group("text/animators").is_some()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a text layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected effect groups (layer, effect group).
|
||||
fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
|
||||
let Some(c) = s.active_comp() else { return vec![] };
|
||||
s.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.filter_map(|(l, u)| {
|
||||
let layer = c.layer(*l)?;
|
||||
let fx = layer.effects()?;
|
||||
fx.groups().find(|g| g.uid == *u).map(|g| (*l, g.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- presets
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
|
||||
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
|
||||
let mut props = vec![];
|
||||
for (lid, uid) in selected_leaf_props(s) {
|
||||
let Some(l) = comp.layer(lid) else { continue };
|
||||
// Properties inside a saved effect travel with the effect.
|
||||
if l.effects().is_some_and(|fx| fx.groups().any(|g| effect_uids.contains(&g.uid) && g.find(uid).is_some())) {
|
||||
continue;
|
||||
}
|
||||
let (Some(pr), Some(mp)) = (l.props.find(uid), match_path_of(&l.props, uid)) else { continue };
|
||||
props.push((l.layer_time(t), mp, pr.clone()));
|
||||
}
|
||||
if props.is_empty() && effects.is_empty() {
|
||||
return Err(bad("anim.savePreset", "select properties or effects to save"));
|
||||
}
|
||||
// Key times are stored relative to the earliest key so the preset applies at the CTI.
|
||||
let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
|
||||
let props_json: Vec<Value> = props
|
||||
.iter()
|
||||
.map(|(_, mp, pr)| {
|
||||
let mut pr = pr.clone();
|
||||
for k in &mut pr.keys {
|
||||
k.time -= first;
|
||||
}
|
||||
json!({"path": mp, "value": pr.value, "keys": pr.keys, "expression": pr.expr})
|
||||
})
|
||||
.collect();
|
||||
let fx_json: Vec<Value> = effects.iter().map(|(_, g)| serde_json::to_value(g).unwrap_or_default()).collect();
|
||||
let name = str_p(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
|
||||
let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
|
||||
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
|
||||
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc: Value = match (p.get("preset"), str_p(p, "path")) {
|
||||
(Some(v), _) => v.clone(),
|
||||
(None, Some(path)) => {
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| bad("anim.applyPreset", format!("not a preset: {e}")))?
|
||||
}
|
||||
_ => return Err(bad("anim.applyPreset", "missing `path` (or inline `preset`)")),
|
||||
};
|
||||
if doc.get(PRESET_TAG).is_none() {
|
||||
return Err(bad("anim.applyPreset", "not an EffectCraft animation preset"));
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("anim.applyPreset", "select a layer"));
|
||||
}
|
||||
let effects: Vec<PropGroup> =
|
||||
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
|
||||
let props: Vec<(String, Property)> = doc["properties"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| {
|
||||
let path = v["path"].as_str()?.to_string();
|
||||
let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
|
||||
let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
|
||||
let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
|
||||
let mut pr = Property::new(0, "", "", value);
|
||||
pr.keys = keys;
|
||||
pr.expr = expr;
|
||||
Some((path, pr))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let t = s.time();
|
||||
let mut applied = 0;
|
||||
let mut skipped = vec![];
|
||||
s.edit("Apply Animation Preset", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &ids {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(fx) = l.props.sub_mut("effects") {
|
||||
for g in &effects {
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
next += 1;
|
||||
fx.children.push(Node::Group(g));
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
for (path, src) in &props {
|
||||
match l.props.prop_mut(path) {
|
||||
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
|
||||
pr.value = src.value.clone();
|
||||
pr.keys = src
|
||||
.keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut k| {
|
||||
k.time += lt;
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
if src.expr.is_some() {
|
||||
pr.expr = src.expr.clone();
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
_ => skipped.push(path.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"applied": applied, "skipped": skipped}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- keyframes
|
||||
|
||||
fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let targets = selected_leaf_props(s);
|
||||
if targets.is_empty() {
|
||||
return Err(bad("anim.addKeyframe", "select properties first"));
|
||||
}
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
let n = s.edit("Add Keyframe", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(pr) = l.props.find_mut(*uid) else { continue };
|
||||
if pr.static_only {
|
||||
continue;
|
||||
}
|
||||
if pr.keys.is_empty() {
|
||||
pr.set_animated(true, lt);
|
||||
} else {
|
||||
let v = pr.value_at(lt);
|
||||
pr.set_value_at(lt, v);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
/// Selected keys grouped by (layer, property).
|
||||
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
|
||||
for k in &s.state.selected_keys {
|
||||
m.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn exponential_scale(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
let groups = keys_by_prop(s);
|
||||
let mut done = 0;
|
||||
let mut work = vec![];
|
||||
for ((lid, uid), mut times) in groups {
|
||||
let Some(l) = comp.layer(lid) else { continue };
|
||||
if l.transform().and_then(|tr| tr.get("scale")).map(|p| p.uid) != Some(uid) {
|
||||
continue;
|
||||
}
|
||||
times.sort();
|
||||
if times.len() >= 2 {
|
||||
work.push((lid, uid, times[0], *times.last().unwrap_or(×[0])));
|
||||
}
|
||||
}
|
||||
if work.is_empty() {
|
||||
return Err(bad("keys.exponentialScale", "select two Scale keyframes"));
|
||||
}
|
||||
s.edit("Exponential Scale", None, |proj, st| {
|
||||
for (lid, uid, a, b) in &work {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let Some(pr) = l.props.find_mut(*uid) else { continue };
|
||||
let (va, vb) = (pr.value_at(*a).as_vec3(), pr.value_at(*b).as_vec3());
|
||||
pr.keys.retain(|k| k.time <= *a || k.time >= *b);
|
||||
let n = ((*b - *a).0 / fd.0.max(1)).max(1);
|
||||
for i in 0..=n {
|
||||
let u = i as f64 / n as f64;
|
||||
let t = if i == n { *b } else { *a + Tick(fd.0 * i) };
|
||||
let f = |x: f64, y: f64| if x.abs() < 1e-9 || y.abs() < 1e-9 || x.signum() != y.signum() { x + (y - x) * u } else { x * (y / x).powf(u) };
|
||||
let v = [f(va[0], vb[0]), f(va[1], vb[1]), f(va[2], vb[2])];
|
||||
let mut k = Keyframe::new(t, KV::Vec3(v));
|
||||
k.in_interp = Interp::Linear;
|
||||
k.out_interp = Interp::Linear;
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, k);
|
||||
st.selected_keys.push(KeyRef { layer: *lid, prop: *uid, time: t });
|
||||
}
|
||||
done += 1;
|
||||
}
|
||||
st.selected_keys.sort_by_key(|k| (k.layer.0, k.prop, k.time));
|
||||
st.selected_keys.dedup();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"properties": done}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- text animators
|
||||
|
||||
/// Animation ▸ Add Text Selector ▸ Range / Wiggly / Expression (see `layer.addTextSelector`).
|
||||
fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::text_anim::add_selector(s, p)
|
||||
}
|
||||
|
||||
fn remove_all_animators(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Remove All Text Animators", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
if let Some(a) = layer_mut(proj, cid, *lid)?.props.group_mut("text/animators") {
|
||||
a.children.clear();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reveal
|
||||
|
||||
/// Whether a static property differs from the value a fresh layer would have. Transform
|
||||
/// defaults depend on the source size and comp size.
|
||||
fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [f64; 2]) -> bool {
|
||||
if pr.is_animated() || pr.expr.is_some() {
|
||||
return true;
|
||||
}
|
||||
let v = &pr.value;
|
||||
let near = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6);
|
||||
match (group, pr.match_id.as_str()) {
|
||||
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
|
||||
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
|
||||
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
|
||||
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
|
||||
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
|
||||
("transform", _) => v.as_f64().abs() > 1e-6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layers: Vec<LayerId> = if s.state.selected_layers.is_empty() { comp.layers.iter().map(|l| l.id).collect() } else { s.state.selected_layers.clone() };
|
||||
let mut out = vec![];
|
||||
for lid in &layers {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
|
||||
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
|
||||
for top in l.props.groups() {
|
||||
let hit = |pr: &Property| match kind.as_str() {
|
||||
"keyframes" => pr.is_animated(),
|
||||
"animation" => pr.is_animated() || pr.has_expression(),
|
||||
_ => modified(pr, &top.match_id, anchor, center),
|
||||
};
|
||||
let mut found = vec![];
|
||||
top.walk("", &mut |_, pr| {
|
||||
if hit(pr) {
|
||||
found.push(pr.uid);
|
||||
}
|
||||
});
|
||||
// Modified: effects and masks count as modifications in full (AE reveals them).
|
||||
if kind == "modified" && matches!(top.match_id.as_str(), "effects" | "masks") {
|
||||
for g in top.groups() {
|
||||
found.push(g.uid);
|
||||
}
|
||||
}
|
||||
if kind == "modified" && top.match_id == "text" {
|
||||
for g in top.group("animators").into_iter().flat_map(|a| a.groups()) {
|
||||
found.push(g.uid);
|
||||
}
|
||||
}
|
||||
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
|
||||
}
|
||||
}
|
||||
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?;
|
||||
Ok(json!({"props": out}))
|
||||
}
|
||||
|
||||
/// Is `g` an effect/mask group kind (used by tests).
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_indexed(g: &PropGroup) -> bool {
|
||||
matches!(g.kind, GroupKind::Indexed | GroupKind::Mask { .. } | GroupKind::Effect { .. })
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"anim.savePreset",
|
||||
"Save Animation Preset...",
|
||||
["Animation"],
|
||||
None,
|
||||
"{path (.ecpreset), name?} — saves the selected properties/effects",
|
||||
has_selected_props,
|
||||
save_preset
|
||||
),
|
||||
cmd!("anim.applyPreset", "Apply Animation Preset...", ["Animation"], None, "{path | preset, layers?}", has_layers, apply_preset),
|
||||
cmd!("anim.addKeyframe", "Add Keyframe", ["Animation"], None, "{} — keys the selected properties at the CTI", has_selected_props, add_keyframe),
|
||||
cmd!(
|
||||
"keys.exponentialScale",
|
||||
"Exponential Scale",
|
||||
["Animation", "Keyframe Assistant"],
|
||||
None,
|
||||
"{} — two selected Scale keys",
|
||||
has_keys,
|
||||
exponential_scale
|
||||
),
|
||||
cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, animator?: uid|index, kind: range|wiggly|expression}", has_text_layer, add_selector),
|
||||
cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators),
|
||||
cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal),
|
||||
]
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!(
|
||||
"comp.new",
|
||||
"New Composition…",
|
||||
"New Composition...",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
|
|
@ -250,7 +250,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"comp.settings",
|
||||
"Composition Settings…",
|
||||
"Composition Settings...",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
|
|
@ -260,7 +260,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("comp.setPosterTime", "Set Poster Time", ["Composition"], None, "{}", has_comp, poster),
|
||||
cmd!("comp.trimToWorkArea", "Trim Comp to Work Area", ["Composition"], Some("Cmd+Shift+X"), "{comp?}", has_comp, trim_to_wa),
|
||||
cmd!("comp.open", "Open Composition", [], None, "{comp: id|name}", always, open),
|
||||
cmd!("comp.close", "Close Composition", [], Some("Cmd+W"), "{comp?}", has_comp, close),
|
||||
cmd!("comp.close", "Close Composition", [], None, "{comp?}", has_comp, close),
|
||||
cmd!("comp.workArea", "Set Work Area", [], None, "{start?, end?, set?: begin|end (at CTI)}", has_comp, work_area),
|
||||
cmd!("comp.setSwitch", "Composition Switch", [], None, "{switch: hideShy|motionBlur|frameBlending|draft3d, value?}", has_comp, comp_switch),
|
||||
cmd!("comp.addMarker", "Add Marker", [], Some("Num*"), "{time?, comment?}", has_comp, add_marker),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
|
||||
//! Responsive Design — Time.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.region_of_interest.is_some() { Ok(()) } else { Err("draw a region of interest in the viewer first".into()) }
|
||||
}
|
||||
|
||||
/// Crop the comp to `[x, y, w, h]`: the comp shrinks and every unparented layer moves by -x, -y
|
||||
/// (all position keys too) so nothing moves on screen.
|
||||
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
|
||||
let (x, y) = (r[0].round(), r[1].round());
|
||||
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
|
||||
s.edit(label, None, |proj, st| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
|
||||
let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue };
|
||||
let mv = |v: &KV| {
|
||||
let a = v.as_vec3();
|
||||
KV::Vec3([a[0] - x, a[1] - y, a[2]])
|
||||
};
|
||||
pos.value = mv(&pos.value);
|
||||
for k in &mut pos.keys {
|
||||
k.value = mv(&k.value);
|
||||
}
|
||||
}
|
||||
for g in &mut c.guides {
|
||||
g.position -= if g.vertical { x } else { y };
|
||||
}
|
||||
st.region_of_interest = None;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"width": w, "height": h, "offset": [x, y]}))
|
||||
}
|
||||
|
||||
fn crop_to_roi(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let r = s.state.region_of_interest.ok_or_else(|| bad("comp.cropToRegionOfInterest", "no region of interest"))?;
|
||||
crop(s, cid, r, "Crop Comp to Region of Interest")
|
||||
}
|
||||
|
||||
fn crop_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let t = s.time();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let Some([x0, y0, x1, y1]) = effectcraft_render::content_bounds(&ctx, l) else { continue };
|
||||
let (m, _) = ctx.layer_to_comp(l);
|
||||
for (px, py) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] {
|
||||
let v = m.0;
|
||||
let wz = v[2][0] * px + v[2][1] * py + v[2][2];
|
||||
let wz = if wz.abs() < 1e-9 { 1.0 } else { wz };
|
||||
let cx = (v[0][0] * px + v[0][1] * py + v[0][2]) / wz;
|
||||
let cy = (v[1][0] * px + v[1][1] * py + v[1][2]) / wz;
|
||||
b = [b[0].min(cx), b[1].min(cy), b[2].max(cx), b[3].max(cy)];
|
||||
}
|
||||
}
|
||||
if b[0] > b[2] {
|
||||
return Err(bad("comp.cropToLayerBounds", "the selected layers have no visible bounds"));
|
||||
}
|
||||
let (x0, y0) = (b[0].floor(), b[1].floor());
|
||||
crop(s, cid, [x0, y0, b[2].ceil() - x0, b[3].ceil() - y0], "Crop Comp to Selected Layer(s) Bounds")
|
||||
}
|
||||
|
||||
/// Encode straight 8-bit RGBA as PNG.
|
||||
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
let mut out = vec![];
|
||||
image::codecs::png::PngEncoder::new(&mut out).write_image(rgba, w, h, image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
let bg = comp.background;
|
||||
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
|
||||
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved frame to {path}"));
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
|
||||
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
let (start, end, comment) = match str_p(p, "op").unwrap_or("workArea") {
|
||||
"intro" => (Tick::ZERO, t, "Intro"),
|
||||
"outro" => (t, comp.duration, "Outro"),
|
||||
"workArea" => (comp.work_area.0, comp.work_area.1, "Protected Region"),
|
||||
o => return Err(bad("comp.responsiveTime", format!("op: intro|outro|workArea, not `{o}`"))),
|
||||
};
|
||||
if end <= start {
|
||||
return Err(bad("comp.responsiveTime", "move the current time first (the region would be empty)"));
|
||||
}
|
||||
s.edit("Create Protected Region", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.markers.push(Marker { time: start, duration: end - start, comment: comment.into(), protected: true, ..Default::default() });
|
||||
c.markers.sort_by_key(|m| m.time);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
|
||||
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
|
||||
cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame),
|
||||
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
|
||||
]
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
//! Edit menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::{Result, Session, cmd};
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, LinkClip, Result, Session, cmd};
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
|
||||
|
|
@ -15,7 +16,25 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
|
||||
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.link_clipboard.is_none() {
|
||||
Err("the clipboard is empty".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn has_key_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
if s.state.key_clipboard.is_empty() { Err("no keyframes on the clipboard".into()) } else { Ok(()) }
|
||||
}
|
||||
fn has_props_or_layers(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_layers.is_empty() && s.state.selected_props.is_empty() { Err("select layers or properties first".into()) } else { Ok(()) }
|
||||
}
|
||||
fn has_props_or_layers_or_items(s: &Session) -> std::result::Result<(), String> {
|
||||
if has_props_or_layers(s).is_ok() || !s.state.project_selection.is_empty() { Ok(()) } else { Err("select layers or project items first".into()) }
|
||||
}
|
||||
fn has_selected_footage(s: &Session) -> std::result::Result<(), String> {
|
||||
if original_path(s).is_some() { Ok(()) } else { Err("select a footage item or layer".into()) }
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -100,18 +119,187 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(ids.len()))
|
||||
}
|
||||
|
||||
fn clear_clipboards(s: &mut Session) {
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers.
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
return s.execute("keys.copy", json!({}));
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.state.clip_is_keys = false;
|
||||
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
s.state.clipboard = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
|
||||
let layers: Vec<Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
|
||||
clear_clipboards(s);
|
||||
s.state.clipboard = layers;
|
||||
Ok(json!(s.state.clipboard.len()))
|
||||
}
|
||||
|
||||
/// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`.
|
||||
pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option<String> {
|
||||
let cid = s.active_comp_id()?;
|
||||
let comp = s.project.comp(cid)?;
|
||||
let l = comp.layer(lid)?;
|
||||
let names = l.props.name_path_of(uid)?;
|
||||
let matches = match_path_of(&l.props, uid)?;
|
||||
let names: Vec<&str> = names.split('/').collect();
|
||||
let first = matches.split('/').next()?.to_string();
|
||||
let q = |x: &str| serde_json::to_string(x).unwrap_or_default();
|
||||
let base = if relative { format!("thisComp.layer({})", q(&l.name)) } else { format!("comp({}).layer({})", q(&s.project.item(cid)?.name), q(&l.name)) };
|
||||
let mut out = match first.as_str() {
|
||||
"transform" => format!("{base}.transform"),
|
||||
"effects" => format!("{base}.effect({})", q(names.get(1)?)),
|
||||
"masks" => format!("{base}.mask({})", q(names.get(1)?)),
|
||||
"text" => format!("{base}.text"),
|
||||
"contents" => format!("{base}.content({})", q(names.get(1)?)),
|
||||
_ => return None,
|
||||
};
|
||||
let skip = if matches!(first.as_str(), "effects" | "masks" | "contents") { 2 } else { 1 };
|
||||
for n in names.iter().skip(skip) {
|
||||
out.push_str(&format!("({})", q(n)));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn copy_links(s: &mut Session, p: &Value, relative: bool) -> Result<Value> {
|
||||
let props = selected_leaf_props(s);
|
||||
if props.is_empty() {
|
||||
// Layers: copy them with every animatable transform/effect property linked to the source.
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut layers = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let mut copy = l.clone();
|
||||
let mut uids = vec![];
|
||||
for g in ["transform", "effects"] {
|
||||
if let Some(grp) = l.props.sub(g) {
|
||||
grp.walk("", &mut |_, pr| {
|
||||
if !pr.static_only && !pr.hold_only {
|
||||
uids.push(pr.uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for uid in uids {
|
||||
if let (Some(e), Some(pr)) = (link_expression(s, l.id, uid, relative), copy.props.find_mut(uid)) {
|
||||
pr.expr = Some(Expression { text: e, enabled: true });
|
||||
}
|
||||
}
|
||||
layers.push(copy);
|
||||
}
|
||||
let n = layers.len();
|
||||
clear_clipboards(s);
|
||||
s.state.clipboard = layers;
|
||||
return Ok(json!({"layers": n}));
|
||||
}
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut links = vec![];
|
||||
for (lid, uid) in &props {
|
||||
let Some(path) = comp.layer(*lid).and_then(|l| match_path_of(&l.props, *uid)) else { continue };
|
||||
if let Some(e) = link_expression(s, *lid, *uid, relative) {
|
||||
links.push((path, e));
|
||||
}
|
||||
}
|
||||
let n = links.len();
|
||||
clear_clipboards(s);
|
||||
s.state.link_clipboard = Some(LinkClip::Links { relative, links });
|
||||
Ok(json!({"links": n}))
|
||||
}
|
||||
|
||||
fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut out = vec![];
|
||||
for (lid, uid) in selected_leaf_props(s) {
|
||||
let Some(l) = comp.layer(lid) else { continue };
|
||||
if let (Some(pr), Some(path)) = (l.props.find(uid), match_path_of(&l.props, uid))
|
||||
&& let Some(e) = &pr.expr
|
||||
{
|
||||
out.push((path, e.clone()));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("the selected properties have no expressions".into()));
|
||||
}
|
||||
let n = out.len();
|
||||
clear_clipboards(s);
|
||||
s.state.link_clipboard = Some(LinkClip::Expressions(out));
|
||||
Ok(json!({"expressions": n}))
|
||||
}
|
||||
|
||||
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
|
||||
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
|
||||
}
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
let t = s.time();
|
||||
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
|
||||
let mut n = 0;
|
||||
st.selected_keys.clear();
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for c in &clip {
|
||||
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
|
||||
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
for k in &c.keys {
|
||||
let mut k = k.clone();
|
||||
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
|
||||
continue;
|
||||
}
|
||||
if reversed {
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
}
|
||||
k.time += lt;
|
||||
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, k);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select a layer to paste into".into()));
|
||||
}
|
||||
let pairs: Vec<(String, Expression)> = match clip {
|
||||
LinkClip::Links { links, .. } => links.into_iter().map(|(p, e)| (p, Expression { text: e, enabled: true })).collect(),
|
||||
LinkClip::Expressions(v) => v,
|
||||
};
|
||||
let n = s.edit("Paste", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
for (path, e) in &pairs {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.expr = Some(e.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"expressions": n}))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.execute("keys.copy", json!({}))?;
|
||||
|
|
@ -122,6 +310,9 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(clip) = s.state.link_clipboard.clone() {
|
||||
return paste_links(s, clip);
|
||||
}
|
||||
if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() {
|
||||
return s.execute("keys.paste", p.clone());
|
||||
}
|
||||
|
|
@ -196,6 +387,123 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
paste_keys(s, true)
|
||||
}
|
||||
|
||||
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
|
||||
/// unlocked layers when none are selected).
|
||||
fn work_area_cut(s: &mut Session, p: &Value, extract: bool) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let (a, b) = comp.work_area;
|
||||
let ids: Vec<LayerId> = match layers_p(s, p)?.1 {
|
||||
v if v.is_empty() => comp.layers.iter().map(|l| l.id).collect(),
|
||||
v => v,
|
||||
};
|
||||
let gap = b - a;
|
||||
s.edit(if extract { "Extract Work Area" } else { "Lift Work Area" }, None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let shift = |l: &mut Layer, d: Tick| {
|
||||
l.start_time += d;
|
||||
l.in_point += d;
|
||||
l.out_point += d;
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < c.layers.len() {
|
||||
if !ids.contains(&c.layers[i].id) || c.layers[i].switches.locked {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (lin, lout) = (c.layers[i].in_point, c.layers[i].out_point);
|
||||
if lout <= a {
|
||||
i += 1;
|
||||
} else if lin >= b {
|
||||
if extract {
|
||||
shift(&mut c.layers[i], Tick::ZERO - gap);
|
||||
}
|
||||
i += 1;
|
||||
} else if lin >= a && lout <= b {
|
||||
c.layers.remove(i);
|
||||
} else if lin < a && lout > b {
|
||||
// Split around the work area: the layer keeps the head, a copy takes the tail.
|
||||
let mut tail = c.layers[i].clone();
|
||||
reid(&mut tail, &mut next);
|
||||
tail.in_point = b;
|
||||
if extract {
|
||||
shift(&mut tail, Tick::ZERO - gap);
|
||||
}
|
||||
c.layers[i].out_point = a;
|
||||
c.layers.insert(i, tail);
|
||||
i += 2;
|
||||
} else if lin < a {
|
||||
c.layers[i].out_point = a;
|
||||
i += 1;
|
||||
} else {
|
||||
c.layers[i].in_point = b;
|
||||
if extract {
|
||||
shift(&mut c.layers[i], Tick::ZERO - gap);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let alive: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
|
||||
st.selected_layers.retain(|l| alive.contains(l));
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn lift(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
work_area_cut(s, p, false)
|
||||
}
|
||||
fn extract(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
work_area_cut(s, p, true)
|
||||
}
|
||||
|
||||
fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let labels: Vec<Label> = s.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).map(|l| l.label).collect();
|
||||
if labels.is_empty() {
|
||||
// Project panel: items with the same labels.
|
||||
let items: Vec<Label> = s.state.project_selection.iter().filter_map(|i| s.project.item(*i)).map(|i| i.label).collect();
|
||||
s.state.project_selection = s.project.items.values().filter(|i| items.contains(&i.label)).map(|i| i.id).collect();
|
||||
return Ok(json!({"items": s.state.project_selection.len()}));
|
||||
}
|
||||
s.state.selected_layers = comp.layers.iter().filter(|l| labels.contains(&l.label)).map(|l| l.id).collect();
|
||||
Ok(json!({"layers": s.state.selected_layers.len()}))
|
||||
}
|
||||
|
||||
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("all").to_string();
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
}
|
||||
|
||||
/// Path of the footage behind the selected layer or project item.
|
||||
fn original_path(s: &Session) -> Option<String> {
|
||||
let comp = s.active_comp();
|
||||
let from_layer = s.state.selected_layers.first().and_then(|l| comp?.layer(*l)).and_then(|l| match l.source {
|
||||
LayerSource::Footage { item } => Some(item),
|
||||
_ => None,
|
||||
});
|
||||
let item = from_layer.or_else(|| s.state.project_selection.first().copied())?;
|
||||
match &s.project.item(item)?.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(f.path.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
|
||||
let url = format!("file://{path}");
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
||||
fn purge(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.history.undo.clear();
|
||||
s.history.redo.clear();
|
||||
|
|
@ -209,14 +517,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clip, paste),
|
||||
cmd!(
|
||||
"edit.copyWithPropertyLinks",
|
||||
"Copy with Property Links",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+C"),
|
||||
"{layers?} (selected properties, or layers, as expressions linking to the originals)",
|
||||
has_props_or_layers,
|
||||
|s, p| copy_links(s, p, false)
|
||||
),
|
||||
cmd!(
|
||||
"edit.copyWithRelativePropertyLinks",
|
||||
"Copy with Relative Property Links",
|
||||
["Edit"],
|
||||
None,
|
||||
"{layers?} (like Copy with Property Links, using thisComp)",
|
||||
has_props_or_layers,
|
||||
|s, p| copy_links(s, p, true)
|
||||
),
|
||||
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
|
||||
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
|
||||
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
|
||||
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
|
||||
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
|
||||
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all),
|
||||
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label),
|
||||
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
|
||||
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
|
||||
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
|
||||
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,16 +141,16 @@ fn cycle_depth(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("file.newProject", "New Project", ["File", "New"], Some("Cmd+Alt+N"), "{}", always, new_project),
|
||||
cmd!("file.openDemoProject", "Open Demo Project", ["File"], None, "{}", always, demo),
|
||||
cmd!("file.open", "Open Project…", ["File"], Some("Cmd+O"), "{path}", always, open),
|
||||
cmd!("file.openDemoProject", "Open Demo Project", ["Help"], None, "{}", always, demo),
|
||||
cmd!("file.open", "Open Project...", ["File"], Some("Cmd+O"), "{path}", always, open),
|
||||
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", always, save),
|
||||
cmd!("file.saveAs", "Save As…", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as),
|
||||
cmd!("file.saveAs", "Save As...", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as),
|
||||
cmd!("file.incrementAndSave", "Increment and Save", ["File"], Some("Cmd+Alt+Shift+S"), "{}", has_path, increment_save),
|
||||
cmd!("file.revert", "Revert", ["File"], None, "{}", has_path, revert),
|
||||
cmd!("file.import", "File…", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import),
|
||||
cmd!("file.import", "File...", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import),
|
||||
cmd!(
|
||||
"file.projectSettings",
|
||||
"Project Settings…",
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,681 @@
|
|||
//! File menu, part two: folders, close, save a copy, import placeholders/solids, New Comp from
|
||||
//! Selection, Dependencies (collect / consolidate / remove unused / reduce / find missing),
|
||||
//! scripts, footage interpretation, replace/reload footage, reveal in Finder.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, f_p, has_comp, has_project_selection, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if selected_footage(s).is_empty() { Err("select footage in the Project panel".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_interpretation(s: &Session) -> std::result::Result<(), String> {
|
||||
has_footage_selection(s)?;
|
||||
if s.state.interpretation.is_none() { Err("no interpretation remembered".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_items(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.project.items.is_empty() { Err("the project is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) { Ok(()) } else { Err("select compositions in the Project panel".into()) }
|
||||
}
|
||||
|
||||
/// Selected project items that are footage (or the source of the selected layer).
|
||||
fn selected_footage(s: &Session) -> Vec<ItemId> {
|
||||
let mut v: Vec<ItemId> =
|
||||
s.state.project_selection.iter().copied().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).collect();
|
||||
if v.is_empty()
|
||||
&& let Some(c) = s.active_comp()
|
||||
{
|
||||
v = s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.filter_map(|l| c.layer(*l))
|
||||
.filter_map(|l| if let LayerSource::Footage { item } = l.source { Some(item) } else { None })
|
||||
.collect();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn items_p(s: &Session, p: &Value) -> Vec<ItemId> {
|
||||
match p.get("items").or(p.get("item")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
|
||||
_ => selected_footage(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name").unwrap_or("Untitled Folder").to_string();
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(ItemId).filter(|i| s.project.item(*i).is_some_and(|x| x.is_folder()));
|
||||
let id = s.edit("New Folder", None, |proj, st| {
|
||||
let id = proj.add_item(&name, Label::Yellow, parent, ItemKind::Folder);
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"item": id.0}))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.execute("comp.close", p.clone())
|
||||
}
|
||||
|
||||
fn close_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.replace_project(Project::default(), None);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?;
|
||||
let json = s.project.to_json();
|
||||
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": json.len()}))
|
||||
}
|
||||
|
||||
fn rate_of(p: &Value) -> FrameRate {
|
||||
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
|
||||
}
|
||||
|
||||
fn placeholder_footage(p: &Value) -> Footage {
|
||||
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
|
||||
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
|
||||
Footage {
|
||||
path: String::new(),
|
||||
kind: FootageKind::Video,
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate_of(p),
|
||||
native_rate: None,
|
||||
duration: Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(30.0)),
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Straight,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: "Placeholder".into(),
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn import_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name").unwrap_or("Placeholder").to_string();
|
||||
let f = placeholder_footage(p);
|
||||
let id = s.edit("Import Placeholder", None, |proj, st| {
|
||||
let id = proj.add_item(&name, Label::Aqua, None, ItemKind::Footage(f));
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"item": id.0}))
|
||||
}
|
||||
|
||||
fn solid_from(p: &Value, s: &Session) -> Solid {
|
||||
let (cw, ch) = s.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
|
||||
let color = match p.get("color") {
|
||||
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r, c.g, c.b]),
|
||||
Some(Value::Array(a)) => Some([0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32)),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or([0.5, 0.5, 0.5]);
|
||||
Solid {
|
||||
color,
|
||||
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
|
||||
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
|
||||
pixel_aspect: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn import_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let so = solid_from(p, s);
|
||||
let name = str_p(p, "name").unwrap_or("Solid").to_string();
|
||||
let id = s.edit("Import Solid", None, |proj, st| {
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let id = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(so));
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"item": id.0}))
|
||||
}
|
||||
|
||||
fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items: Vec<ItemId> = s.state.project_selection.iter().copied().filter(|i| s.project.item(*i).is_some_and(|x| !x.is_folder())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.newCompFromSelection", "select footage in the Project panel"));
|
||||
}
|
||||
let mut made = vec![];
|
||||
for item in items {
|
||||
let it = s.project.item(item).ok_or(EngineError::NoComp)?.clone();
|
||||
let (w, h) = it.dimensions().unwrap_or((1920, 1080));
|
||||
let rate = it.frame_rate().unwrap_or(FrameRate::FPS_29_97);
|
||||
let dur = it.duration().filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(10.0)));
|
||||
let name = it.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(it.name.clone());
|
||||
let c = Comp::new(w.max(1), h.max(1), rate, dur);
|
||||
let cid = s.edit("New Comp from Selection", None, |proj, st| {
|
||||
let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
st.project_selection = vec![cid];
|
||||
Ok(cid)
|
||||
})?;
|
||||
s.open_comp(cid);
|
||||
s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0}))?;
|
||||
made.push(cid.0);
|
||||
}
|
||||
Ok(json!({"comps": made}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dependencies
|
||||
|
||||
/// Items used (transitively) by `roots`.
|
||||
fn used_items(proj: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack: Vec<ItemId> = roots.to_vec();
|
||||
while let Some(i) = stack.pop() {
|
||||
if !seen.insert(i) {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = proj.comp(i) {
|
||||
stack.extend(c.layers.iter().filter_map(|l| l.source.item()));
|
||||
}
|
||||
// Parent folders stay so the structure is kept.
|
||||
if let Some(parent) = proj.item(i).and_then(|x| x.parent) {
|
||||
stack.push(parent);
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
fn referenced_sources(proj: &Project) -> BTreeSet<ItemId> {
|
||||
proj.comps().flat_map(|(_, c)| c.layers.iter().filter_map(|l| l.source.item())).collect()
|
||||
}
|
||||
|
||||
fn remove_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let used = referenced_sources(&s.project);
|
||||
let n = s.edit("Remove Unused Footage", None, |proj, _| {
|
||||
let before = proj.items.len();
|
||||
proj.items.retain(|id, it| !matches!(it.kind, ItemKind::Footage(_) | ItemKind::Solid(_)) || used.contains(id));
|
||||
Ok(before - proj.items.len())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
s.toast(format!("Removed {n} unused footage item(s)"));
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn consolidate(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// Footage with the same file and interpretation collapses into the first such item.
|
||||
let mut first: BTreeMap<String, ItemId> = BTreeMap::new();
|
||||
let mut remap: BTreeMap<ItemId, ItemId> = BTreeMap::new();
|
||||
for (id, it) in &s.project.items {
|
||||
if let ItemKind::Footage(f) = &it.kind
|
||||
&& !f.path.is_empty()
|
||||
{
|
||||
let key = serde_json::to_string(f).unwrap_or_default();
|
||||
match first.get(&key) {
|
||||
Some(keep) => {
|
||||
remap.insert(*id, *keep);
|
||||
}
|
||||
None => {
|
||||
first.insert(key, *id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = remap.len();
|
||||
s.edit("Consolidate All Footage", None, |proj, _| {
|
||||
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in ids {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in &mut c.layers {
|
||||
if let LayerSource::Footage { item } = &mut l.source
|
||||
&& let Some(k) = remap.get(item)
|
||||
{
|
||||
*item = *k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.items.retain(|id, _| !remap.contains_key(id));
|
||||
Ok(())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
s.toast(format!("Consolidated {n} duplicate footage item(s)"));
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn reduce(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots: Vec<ItemId> = s.state.project_selection.iter().copied().filter(|i| s.project.comp(*i).is_some()).collect();
|
||||
let keep = used_items(&s.project, &roots);
|
||||
let n = s.edit("Reduce Project", None, |proj, _| {
|
||||
let before = proj.items.len();
|
||||
proj.items.retain(|id, _| keep.contains(id));
|
||||
Ok(before - proj.items.len())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
s.toast(format!("Removed {n} item(s)"));
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("footage");
|
||||
match what {
|
||||
"footage" => {
|
||||
let ids: Vec<ItemId> = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.missing)).map(|i| i.id).collect();
|
||||
s.state.project_selection = ids.clone();
|
||||
s.toast(format!("{} missing footage item(s)", ids.len()));
|
||||
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
"effects" => {
|
||||
let mut found = vec![];
|
||||
for (cid, c) in s.project.comps() {
|
||||
for l in &c.layers {
|
||||
for g in l.effects().into_iter().flat_map(|fx| fx.groups()) {
|
||||
if let effectcraft_project::GroupKind::Effect { effect } = &g.kind
|
||||
&& crate::effects::find(effect).is_none()
|
||||
{
|
||||
found.push(json!({"comp": cid.0, "layer": l.id.0, "effect": effect}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.toast(format!("{} missing effect(s)", found.len()));
|
||||
Ok(json!({"effects": found}))
|
||||
}
|
||||
"fonts" => {
|
||||
let families = crate::text_families();
|
||||
let mut found = vec![];
|
||||
for (cid, c) in s.project.comps() {
|
||||
for l in &c.layers {
|
||||
if let Some(pr) = l.props.prop("text/sourceText")
|
||||
&& let Some(doc) = pr.value.as_text()
|
||||
&& !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font))
|
||||
{
|
||||
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.toast(format!("{} layer(s) with missing fonts", found.len()));
|
||||
Ok(json!({"fonts": found}))
|
||||
}
|
||||
w => Err(bad("file.findMissing", format!("what: footage|effects|fonts, not `{w}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let folder = str_p(p, "folder").ok_or_else(|| bad("file.collectFiles", "missing `folder`"))?.trim_end_matches('/').to_string();
|
||||
let mut proj = (*s.project).clone();
|
||||
let mut copied = 0;
|
||||
let mut errors = vec![];
|
||||
let mut used_names = BTreeSet::new();
|
||||
for it in proj.items.values_mut() {
|
||||
let ItemKind::Footage(f) = &mut it.kind else { continue };
|
||||
if f.path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
|
||||
let mut new_paths = vec![];
|
||||
for src in &files {
|
||||
let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into());
|
||||
let mut k = 2;
|
||||
while !used_names.insert(name.clone()) {
|
||||
name = format!("{k}_{name}");
|
||||
k += 1;
|
||||
}
|
||||
let dst = format!("{folder}/(Footage)/{name}");
|
||||
match s.services.read_file(src).and_then(|bytes| s.services.write_file(&dst, &bytes)) {
|
||||
Ok(()) => copied += 1,
|
||||
Err(e) => errors.push(format!("{src}: {e}")),
|
||||
}
|
||||
new_paths.push(dst);
|
||||
}
|
||||
if f.sequence.is_empty() {
|
||||
f.path = new_paths.remove(0);
|
||||
} else {
|
||||
f.path = new_paths.first().cloned().unwrap_or_default();
|
||||
f.sequence = new_paths;
|
||||
}
|
||||
}
|
||||
let stem = s
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).file_stem())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Untitled Project".into());
|
||||
let out = format!("{folder}/{stem}.ecproj");
|
||||
s.services.write_file(&out, proj.to_json().as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?;
|
||||
s.toast(format!("Collected {copied} file(s) into {folder}"));
|
||||
Ok(json!({"project": out, "copied": copied, "errors": errors}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scripts
|
||||
|
||||
/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also
|
||||
/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
_ => text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//") && !l.starts_with('#'))
|
||||
.map(|l| serde_json::from_str(l).map_err(|e| bad("file.runScript", format!("{e}: {l}"))))
|
||||
.collect::<Result<Vec<Value>>>()?,
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")),
|
||||
};
|
||||
let mut results = vec![];
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) {
|
||||
(Some(id), _) => (id.to_string(), step.get("params").cloned().unwrap_or(json!({}))),
|
||||
(None, Some("engine.execute")) => {
|
||||
let pp = step.get("params").cloned().unwrap_or(json!({}));
|
||||
(pp.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), pp.get("params").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
results.push(r);
|
||||
}
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
|
||||
let alpha = match str_p(p, "alpha") {
|
||||
Some("straight") => Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => Some(AlphaMode::Ignore),
|
||||
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
|
||||
None => None,
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
if let Some(r) = rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some(l) = loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.rememberInterpretation", "select footage"))?;
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(item).map(|x| &x.kind) {
|
||||
s.state.interpretation = Some(f.clone());
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = s.state.interpretation.clone().ok_or_else(|| bad("file.applyInterpretation", "no interpretation remembered"))?;
|
||||
let items = items_p(s, p);
|
||||
s.edit("Apply Interpretation", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
f.alpha = src.alpha;
|
||||
f.premul_color = src.premul_color;
|
||||
f.pixel_aspect = src.pixel_aspect;
|
||||
f.loop_count = src.loop_count;
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = src.frame_rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.len()}))
|
||||
}
|
||||
|
||||
fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
|
||||
it.name = name;
|
||||
it.kind = ItemKind::Footage(f);
|
||||
retarget_layers(proj, item, |_| LayerSource::Footage { item });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Point every layer using `item` at a new source kind.
|
||||
fn retarget_layers(proj: &mut Project, item: ItemId, src: impl Fn(&LayerSource) -> LayerSource) {
|
||||
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in ids {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
for l in &mut c.layers {
|
||||
if l.source.item() == Some(item) {
|
||||
l.source = src(&l.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_with_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceWithPlaceholder", "select footage"))?;
|
||||
let base = match s.project.item(item).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => json!({"width": f.width, "height": f.height, "frameRate": f.frame_rate.as_f64(), "duration": f.duration.seconds()}),
|
||||
_ => json!({}),
|
||||
};
|
||||
let mut merged = base;
|
||||
if let (Some(m), Some(pm)) = (merged.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in pm {
|
||||
m.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
let f = placeholder_footage(&merged);
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceWithPlaceholder", "no such item"))?;
|
||||
it.kind = ItemKind::Footage(f);
|
||||
retarget_layers(proj, item, |_| LayerSource::Footage { item });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceWithSolid", "select footage"))?;
|
||||
let mut so = solid_from(p, s);
|
||||
if let Some((w, h)) = s.project.item(item).and_then(|x| x.dimensions())
|
||||
&& p.get("width").is_none()
|
||||
{
|
||||
so.width = w.max(1);
|
||||
so.height = h.max(1);
|
||||
}
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceWithSolid", "no such item"))?;
|
||||
it.kind = ItemKind::Solid(so);
|
||||
retarget_layers(proj, item, |_| LayerSource::Solid { item });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
let Some(importer) = s.importer.clone() else { return Err(EngineError::Other("media import is not available in this build".into())) };
|
||||
let mut updates = vec![];
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
let mut nf = match importer.probe(&f.path) {
|
||||
Ok(n) => n,
|
||||
Err(_) => Footage { missing: true, ..f.clone() },
|
||||
};
|
||||
// Keep the interpretation.
|
||||
nf.alpha = f.alpha;
|
||||
nf.loop_count = f.loop_count;
|
||||
nf.pixel_aspect = f.pixel_aspect;
|
||||
updates.push((*i, nf));
|
||||
}
|
||||
}
|
||||
let n = updates.len();
|
||||
s.edit("Reload Footage", None, |proj, _| {
|
||||
for (i, f) in updates {
|
||||
if let Some(it) = proj.item_mut(i) {
|
||||
it.kind = ItemKind::Footage(f);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
Ok(json!({"reloaded": n}))
|
||||
}
|
||||
|
||||
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.revealInFinder", "select footage"))?;
|
||||
let path = match s.project.item(item).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
|
||||
_ => return Err(bad("file.revealInFinder", "the item is not a file")),
|
||||
};
|
||||
let url = super::layer_menu::folder_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("project.newFolder", "New Folder", ["File", "New"], Some("Cmd+Alt+Shift+N"), "{name?, parent?}", always, new_folder),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{comp?}", has_comp, close),
|
||||
cmd!("file.closeProject", "Close Project", ["File"], None, "{}", always, close_project),
|
||||
cmd!("file.saveCopy", "Save a Copy...", ["File", "Save As"], None, "{path}", always, save_copy),
|
||||
cmd!("file.importMultiple", "Multiple Files...", ["File", "Import"], Some("Cmd+Alt+I"), "{paths: [string]}", always, |s, p| s
|
||||
.execute("file.import", p.clone())),
|
||||
cmd!(
|
||||
"file.importPlaceholder",
|
||||
"Placeholder...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{name?, width?, height?, frameRate?, duration? (s)}",
|
||||
always,
|
||||
import_placeholder
|
||||
),
|
||||
cmd!("file.importSolid", "Solid...", ["File", "Import"], None, "{name?, color?, width?, height?}", always, import_solid),
|
||||
cmd!(
|
||||
"file.newCompFromSelection",
|
||||
"New Comp from Selection...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+\\"),
|
||||
"{duration? (s, for stills)}",
|
||||
has_project_selection,
|
||||
new_comp_from_selection
|
||||
),
|
||||
cmd!("file.collectFiles", "Collect Files...", ["File", "Dependencies"], None, "{folder}", has_items, collect_files),
|
||||
cmd!("file.consolidateFootage", "Consolidate All Footage", ["File", "Dependencies"], None, "{}", has_items, consolidate),
|
||||
cmd!("file.removeUnusedFootage", "Remove Unused Footage", ["File", "Dependencies"], None, "{}", has_items, remove_unused),
|
||||
cmd!(
|
||||
"file.reduceProject",
|
||||
"Reduce Project",
|
||||
["File", "Dependencies"],
|
||||
None,
|
||||
"{} (keeps the selected comps and what they use)",
|
||||
has_comp_selection,
|
||||
reduce
|
||||
),
|
||||
cmd!("file.findMissing", "Find Missing", [], None, "{what: footage|effects|fonts}", has_items, find_missing),
|
||||
cmd!(
|
||||
"file.runScript",
|
||||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsonl/.json command script) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
"file.interpretFootage",
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
cmd!(
|
||||
"file.rememberInterpretation",
|
||||
"Remember Interpretation",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{item?}",
|
||||
has_footage_selection,
|
||||
remember_interpretation
|
||||
),
|
||||
cmd!(
|
||||
"file.applyInterpretation",
|
||||
"Apply Interpretation",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{items?}",
|
||||
has_interpretation,
|
||||
apply_interpretation
|
||||
),
|
||||
cmd!("file.replaceFootage", "File...", ["File", "Replace Footage"], Some("Cmd+H"), "{path, item?}", has_footage_selection, replace_footage),
|
||||
cmd!(
|
||||
"file.replaceWithPlaceholder",
|
||||
"Placeholder...",
|
||||
["File", "Replace Footage"],
|
||||
None,
|
||||
"{item?, width?, height?, frameRate?, duration?}",
|
||||
has_footage_selection,
|
||||
replace_with_placeholder
|
||||
),
|
||||
cmd!(
|
||||
"file.replaceWithSolid",
|
||||
"Solid...",
|
||||
["File", "Replace Footage"],
|
||||
None,
|
||||
"{item?, color?, width?, height?}",
|
||||
has_footage_selection,
|
||||
replace_with_solid
|
||||
),
|
||||
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
|
||||
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
|
||||
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
|
||||
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
|
||||
//! performs it. Headless sessions accept and ignore them.
|
||||
|
||||
use super::{CommandSpec, always, frontend, has_comp, has_layers};
|
||||
use crate::cmd;
|
||||
|
||||
macro_rules! fe {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
|
||||
cmd!($id, $label, [$($m),*], $sc, $params, $en, |s, p| frontend(s, $id, p))
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// Application.
|
||||
fe!("app.about", "About EffectCraft...", [], None, "{}", always),
|
||||
fe!(
|
||||
"app.settings",
|
||||
"Settings...",
|
||||
[],
|
||||
None,
|
||||
"{page?: general|startup|project|composition|previews|appearance|grids|labels|type|import|export|audio|disk|memory|video|3d|scripting}",
|
||||
always
|
||||
),
|
||||
fe!("app.hide", "Hide EffectCraft", [], None, "{}", always),
|
||||
fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always),
|
||||
fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always),
|
||||
fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always),
|
||||
fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always),
|
||||
fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always),
|
||||
// Preview.
|
||||
fe!("playback.toggle", "Play Current Preview", ["Composition", "Preview"], Some("Space"), "{}", has_comp),
|
||||
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
|
||||
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
|
||||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
|
||||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
fe!("view.snapToGuides", "Snap to Guides", ["View"], Some("Cmd+Shift+;"), "{value?}", always),
|
||||
fe!("view.lockGuides", "Lock Guides", ["View"], Some("Cmd+Alt+Shift+;"), "{value?}", always),
|
||||
fe!("view.grid", "Show Grid", ["View"], Some("Cmd+'"), "{value?}", always),
|
||||
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
|
||||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
// Panels and workspaces.
|
||||
fe!(
|
||||
"window.panel",
|
||||
"Show Panel",
|
||||
[],
|
||||
None,
|
||||
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools}",
|
||||
always
|
||||
),
|
||||
fe!(
|
||||
"window.workspace",
|
||||
"Workspace",
|
||||
[],
|
||||
None,
|
||||
"{name: Default|Standard|Small Screen|Animation|Effects|Motion Tracking|Paint|Text|Minimal|All Panels}",
|
||||
always
|
||||
),
|
||||
fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always),
|
||||
fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always),
|
||||
fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], None, "{}", has_comp),
|
||||
fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers),
|
||||
fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always),
|
||||
fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always),
|
||||
]
|
||||
}
|
||||
|
|
@ -16,13 +16,26 @@ fn sibling(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if str_p(p, "kind") == Some("github") { open(s, links::github_of(&slug)) } else { open(s, links::page_of(&slug)) }
|
||||
}
|
||||
|
||||
/// Help ▸ EffectCraft Help / Scripting Help / Expression Reference / Effect Reference.
|
||||
fn docs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = match str_p(p, "page").unwrap_or("help") {
|
||||
"scripting" => "docs/control-protocol.md",
|
||||
"expressions" => "docs/expressions.md",
|
||||
"effects" => "docs/effects.md",
|
||||
"agents" => "docs/agents.md",
|
||||
_ => "README.md",
|
||||
};
|
||||
open(s, format!("{}/blob/main/{page}", links::GITHUB))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("help.discord", "Join the ArtCraft Discord…", ["Help"], None, "{}", always, |s, _| open(s, links::DISCORD.into())),
|
||||
cmd!("help.docs", "EffectCraft Help...", [], Some("F1"), "{page?: help|scripting|expressions|effects|agents}", always, docs),
|
||||
cmd!("help.discord", "Join the ArtCraft Discord...", ["Help"], None, "{}", always, |s, _| open(s, links::DISCORD.into())),
|
||||
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
|
||||
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
|
||||
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
|
||||
cmd!("help.reportIssue", "Report an Issue…", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
|
||||
cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
|
||||
cmd!(
|
||||
"help.sibling",
|
||||
"Other ArtCraft Apps",
|
||||
|
|
|
|||
|
|
@ -916,7 +916,7 @@ fn with_layer(p: &Value, lid: LayerId) -> Value {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
|
||||
cmd!("layer.newSolid", "Solid…", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
|
||||
cmd!(
|
||||
"layer.newShape",
|
||||
|
|
@ -928,7 +928,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
new_shape
|
||||
),
|
||||
cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment),
|
||||
cmd!("layer.settings", "Layer Settings…", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings),
|
||||
cmd!("layer.settings", "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
|
|
@ -964,7 +964,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
timing
|
||||
),
|
||||
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange),
|
||||
cmd!("layer.precompose", "Pre-compose…", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose),
|
||||
cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose),
|
||||
cmd!(
|
||||
"layer.addMask",
|
||||
"New Mask",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,805 @@
|
|||
//! Layer menu: quality / sampling / frame blending, switches, transform dialogs, masks on existing
|
||||
//! layers, markers, track matte shortcuts, open/reveal, and Keyframe Assistant ▸ Sequence Layers.
|
||||
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value as KV};
|
||||
use effectcraft_project::{FrameBlend, GroupKind, ItemId, LayerId, LayerSource, Marker, MaskMode, MatteKind, Node, Quality, Sampling, TrackMatte, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, frontend, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
// ---------------------------------------------------------------- enablement
|
||||
|
||||
fn has_masks(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
let any = s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.masks().is_some_and(|m| !m.children.is_empty()));
|
||||
if any { Ok(()) } else { Err("the selected layers have no masks".into()) }
|
||||
}
|
||||
|
||||
fn has_markers(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| !l.markers.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the selected layers have no markers".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_source_item(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.source.item().is_some()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the selected layer has no source item".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_footage_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
let any = s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Footage { .. }));
|
||||
if any { Ok(()) } else { Err("select a footage layer".into()) }
|
||||
}
|
||||
|
||||
fn has_multiple_layers(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_layers.len() >= 2 { Ok(()) } else { Err("select two or more layers".into()) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- quality / sampling / blending
|
||||
|
||||
fn quality(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let q = match str_p(p, "quality").unwrap_or("best").to_ascii_lowercase().as_str() {
|
||||
"best" => Quality::Best,
|
||||
"draft" => Quality::Draft,
|
||||
"wireframe" => Quality::Wireframe,
|
||||
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)) {
|
||||
l.switches.quality = q;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn sampling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let q = match str_p(p, "sampling").unwrap_or("bilinear").to_ascii_lowercase().as_str() {
|
||||
"bilinear" => Sampling::Bilinear,
|
||||
"bicubic" => Sampling::Bicubic,
|
||||
q => return Err(bad("layer.sampling", format!("sampling must be bilinear|bicubic, not `{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)) {
|
||||
l.switches.sampling = q;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let m = match str_p(p, "mode").unwrap_or("off") {
|
||||
"off" => FrameBlend::Off,
|
||||
"frameMix" => FrameBlend::FrameMix,
|
||||
"pixelMotion" => FrameBlend::PixelMotion,
|
||||
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)) {
|
||||
l.switches.frame_blend = m;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- switches
|
||||
|
||||
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && l.source.is_av() {
|
||||
l.switches.video = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn show_all_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.edit("Show All Video", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// Track mattes stay hidden (their video switch is off by design).
|
||||
let mattes: Vec<LayerId> = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect();
|
||||
for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) {
|
||||
l.switches.video = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.edit("Unlock All Layers", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
l.switches.locked = false;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn expressions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
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)) {
|
||||
l.props.walk_mut(&mut |pr| {
|
||||
if let Some(e) = &mut pr.expr {
|
||||
e.enabled = on;
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"expressions": n}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- transform
|
||||
|
||||
fn transform_member(prop: &str) -> Option<&'static str> {
|
||||
Some(match prop {
|
||||
"anchor" | "anchorPoint" => "anchor",
|
||||
"position" => "position",
|
||||
"scale" => "scale",
|
||||
"orientation" => "orientation",
|
||||
"rotation" => "rotation",
|
||||
"opacity" => "opacity",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let prop = str_p(p, "prop").ok_or_else(|| bad("layer.setTransform", "missing `prop`"))?;
|
||||
let m = transform_member(prop).ok_or_else(|| bad("layer.setTransform", "prop: anchor|position|scale|orientation|rotation|opacity"))?;
|
||||
let v = p.get("value").ok_or_else(|| bad("layer.setTransform", "missing `value`"))?.clone();
|
||||
let t = s.time();
|
||||
let label = match m {
|
||||
"anchor" => "Anchor Point",
|
||||
"position" => "Position",
|
||||
"scale" => "Scale",
|
||||
"orientation" => "Orientation",
|
||||
"rotation" => "Rotation",
|
||||
_ => "Opacity",
|
||||
};
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
for lid in &ids {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue };
|
||||
let cur = pr.value_at(lt);
|
||||
// Two numbers for a 3D value keep the current z.
|
||||
let j = match (&cur, &v) {
|
||||
(KV::Vec3(c), Value::Array(a)) if a.len() == 2 => json!([a[0], a[1], c[2]]),
|
||||
_ => v.clone(),
|
||||
};
|
||||
let nv = cur.coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?;
|
||||
pr.set_value_at(lt, nv);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let t = s.time();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
let mut centers = vec![];
|
||||
for lid in &ids {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if let Some([x0, y0, x1, y1]) = effectcraft_render::content_bounds(&ctx, l) {
|
||||
centers.push((*lid, [(x0 + x1) / 2.0, (y0 + y1) / 2.0]));
|
||||
}
|
||||
}
|
||||
s.edit("Center Anchor Point", None, |proj, _| {
|
||||
for (lid, c) in ¢ers {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(tr) = l.transform_mut() else { continue };
|
||||
let get = |tr: &effectcraft_project::PropGroup, m: &str, d: [f64; 3]| tr.get(m).map(|p| p.value_at(lt).as_vec3()).unwrap_or(d);
|
||||
let anchor = get(tr, "anchor", [0.0; 3]);
|
||||
let scale = get(tr, "scale", [100.0; 3]);
|
||||
let rot = tr.get("rotation").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0).to_radians();
|
||||
let (dx, dy) = ((c[0] - anchor[0]) * scale[0] / 100.0, (c[1] - anchor[1]) * scale[1] / 100.0);
|
||||
let (sn, cs) = rot.sin_cos();
|
||||
let shift = [dx * cs - dy * sn, dx * sn + dy * cs];
|
||||
if let Some(a) = tr.get_mut("anchor") {
|
||||
a.set_value_at(lt, KV::Vec3([c[0], c[1], anchor[2]]));
|
||||
}
|
||||
if let Some(pos) = tr.get_mut("position") {
|
||||
// Shift every key so the layer stays put over its whole animation.
|
||||
if pos.keys.is_empty() {
|
||||
let v = pos.value.as_vec3();
|
||||
pos.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]);
|
||||
} else {
|
||||
for k in &mut pos.keys {
|
||||
let v = k.value.as_vec3();
|
||||
k.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layers": centers.len()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- masks
|
||||
|
||||
/// Masks the command applies to: `mask` (index / uid / name) on `layer`, else selected masks,
|
||||
/// else every mask of the selected layers.
|
||||
fn target_masks(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut out = vec![];
|
||||
if let Some(m) = p.get("mask") {
|
||||
for lid in &ids {
|
||||
let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) else { continue };
|
||||
let g = match m {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1);
|
||||
masks.children.iter().find(|c| c.uid() == n).or_else(|| masks.children.get(n.saturating_sub(1) as usize))
|
||||
}
|
||||
Value::String(name) => masks.children.iter().find(|c| c.name() == name),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(g) = g {
|
||||
out.push((*lid, g.uid()));
|
||||
}
|
||||
}
|
||||
return Ok((cid, out));
|
||||
}
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) else { continue };
|
||||
for g in masks.groups() {
|
||||
if g.uid == *uid || g.find(*uid).is_some() {
|
||||
out.push((*lid, g.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
for lid in &ids {
|
||||
if let Some(masks) = comp.layer(*lid).and_then(|l| l.masks()) {
|
||||
out.extend(masks.groups().map(|g| (*lid, g.uid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
Ok((cid, out))
|
||||
}
|
||||
|
||||
fn edit_masks(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
label: &str,
|
||||
mut f: impl FnMut(&mut effectcraft_project::PropGroup, (f64, f64), Tick) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
let (cid, targets) = target_masks(s, p)?;
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("no masks".into()));
|
||||
}
|
||||
let t = s.time();
|
||||
let sizes: Vec<(LayerId, (f64, f64))> = targets
|
||||
.iter()
|
||||
.filter_map(|(lid, _)| s.project.comp(cid)?.layer(*lid).map(|l| (*lid, effectcraft_render::source_size(&s.project, l))))
|
||||
.map(|(l, (w, h))| (l, (w as f64, h as f64)))
|
||||
.collect();
|
||||
let n = targets.len();
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
for (lid, uid) in &targets {
|
||||
let size = sizes.iter().find(|(l, _)| l == lid).map(|x| x.1).unwrap_or((0.0, 0.0));
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
|
||||
f(g, size, lt)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"masks": n}))
|
||||
}
|
||||
|
||||
fn mask_kind(g: &mut effectcraft_project::PropGroup) -> Option<(&mut MaskMode, &mut bool, &mut bool)> {
|
||||
match &mut g.kind {
|
||||
GroupKind::Mask { mode, inverted, locked, .. } => Some((mode, inverted, locked)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let field = str_p(p, "field").ok_or_else(|| bad("layer.mask.set", "missing `field` (feather|opacity|expansion)"))?.to_string();
|
||||
if !matches!(field.as_str(), "feather" | "opacity" | "expansion") {
|
||||
return Err(bad("layer.mask.set", "field: feather|opacity|expansion"));
|
||||
}
|
||||
let v = p.get("value").ok_or_else(|| bad("layer.mask.set", "missing `value`"))?.clone();
|
||||
let label = match field.as_str() {
|
||||
"feather" => "Mask Feather",
|
||||
"opacity" => "Mask Opacity",
|
||||
_ => "Mask Expansion",
|
||||
};
|
||||
edit_masks(s, p, label, |g, _, lt| {
|
||||
let pr = g.get_mut(&field).ok_or_else(|| bad("layer.mask.set", "mask has no such property"))?;
|
||||
let cur = pr.value_at(lt);
|
||||
let j = match (&cur, &v) {
|
||||
(KV::Vec2(_), Value::Number(n)) => json!([n, n]),
|
||||
_ => v.clone(),
|
||||
};
|
||||
let nv = cur.coerce_json(&j).ok_or_else(|| bad("layer.mask.set", format!("can't use {v} for {label}")))?;
|
||||
pr.set_value_at(lt, nv);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = p.get("rect").and_then(Value::as_array).filter(|a| a.len() == 4).map(|a| [0, 1, 2, 3].map(|i| a[i].as_f64().unwrap_or(0.0)));
|
||||
let r = r.ok_or_else(|| bad("layer.mask.shape", "missing `rect` [left, top, width, height] (layer px)"))?;
|
||||
let ellipse = str_p(p, "shape") == Some("ellipse");
|
||||
edit_masks(s, p, "Mask Shape", |g, _, lt| {
|
||||
let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
let path = if ellipse { ShapePath::ellipse([cx, cy], r[2], r[3]) } else { ShapePath::rect([cx, cy], r[2], r[3]) };
|
||||
if let Some(pr) = g.get_mut("path") {
|
||||
pr.set_value_at(lt, KV::Path(path));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_masks(s, p, "Reset Mask", |g, (w, h), _| {
|
||||
let (w, h) = if w > 0.0 { (w, h) } else { (400.0, 300.0) };
|
||||
let reset = |g: &mut effectcraft_project::PropGroup, m: &str, v: KV| {
|
||||
if let Some(pr) = g.get_mut(m) {
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
pr.value = v;
|
||||
}
|
||||
};
|
||||
reset(g, "path", KV::Path(ShapePath::rect([w / 2.0, h / 2.0], w, h)));
|
||||
reset(g, "feather", KV::Vec2([0.0, 0.0]));
|
||||
reset(g, "opacity", KV::Scalar(100.0));
|
||||
reset(g, "expansion", KV::Scalar(0.0));
|
||||
if let Some((mode, inv, _)) = mask_kind(g) {
|
||||
*mode = MaskMode::Add;
|
||||
*inv = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, targets) = target_masks(s, p)?;
|
||||
// Without an explicit `mask`, Remove Mask only removes selected masks (AE), else the first.
|
||||
let selected: Vec<(LayerId, Uid)> = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(l, u)| {
|
||||
p.get("mask").is_some()
|
||||
|| s.state.selected_props.iter().any(|(sl, su)| {
|
||||
sl == l
|
||||
&& (su == u || s.active_comp().and_then(|c| c.layer(*l)).and_then(|ly| ly.props.find_group(*u)).is_some_and(|g| g.find(*su).is_some()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let remove = if selected.is_empty() { targets.into_iter().take(1).collect() } else { selected };
|
||||
remove_masks(s, cid, remove, "Remove Mask")
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, cid: ItemId, remove: Vec<(LayerId, Uid)>, label: &str) -> Result<Value> {
|
||||
let n = remove.len();
|
||||
s.edit(label, None, |proj, st| {
|
||||
for (lid, uid) in &remove {
|
||||
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
|
||||
m.children.retain(|c| c.uid() != *uid);
|
||||
}
|
||||
}
|
||||
st.selected_props.retain(|(l, u)| !remove.iter().any(|(rl, ru)| rl == l && ru == u));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn mask_remove_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let all: Vec<(LayerId, Uid)> =
|
||||
ids.iter().filter_map(|l| comp.layer(*l)).flat_map(|l| l.masks().into_iter().flat_map(|m| m.groups().map(|g| (l.id, g.uid)))).collect();
|
||||
remove_masks(s, cid, all, "Remove All Masks")
|
||||
}
|
||||
|
||||
fn mask_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m =
|
||||
str_p(p, "mode").and_then(MaskMode::from_name).ok_or_else(|| bad("layer.mask.mode", "mode: None|Add|Subtract|Intersect|Lighten|Darken|Difference"))?;
|
||||
edit_masks(s, p, "Mask Mode", |g, _, _| {
|
||||
if let Some((mode, ..)) = mask_kind(g) {
|
||||
*mode = m;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_invert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = b_p(p, "value");
|
||||
edit_masks(s, p, "Mask Inverted", |g, _, _| {
|
||||
if let Some((_, inv, _)) = mask_kind(g) {
|
||||
*inv = v.unwrap_or(!*inv);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = b_p(p, "value");
|
||||
edit_masks(s, p, "Mask Locked", |g, _, _| {
|
||||
if let Some((_, _, locked)) = mask_kind(g) {
|
||||
*locked = v.unwrap_or(!*locked);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Unlock All Masks", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
|
||||
for c in &mut m.children {
|
||||
if let Node::Group(g) = c
|
||||
&& let Some((_, _, locked)) = mask_kind(g)
|
||||
{
|
||||
*locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn mask_lock_others(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, keep) = target_masks(s, p)?;
|
||||
let layers: Vec<LayerId> = keep.iter().map(|(l, _)| *l).collect();
|
||||
s.edit("Lock Other Masks", None, |proj, _| {
|
||||
for lid in &layers {
|
||||
if let Some(m) = layer_mut(proj, cid, *lid)?.props.sub_mut("masks") {
|
||||
for c in &mut m.children {
|
||||
let uid = c.uid();
|
||||
if let Node::Group(g) = c
|
||||
&& let Some((_, _, locked)) = mask_kind(g)
|
||||
{
|
||||
*locked = !keep.contains(&(*lid, uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- markers
|
||||
|
||||
fn add_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.addMarker", "no layer"));
|
||||
}
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
|
||||
let comment = str_p(p, "comment").unwrap_or_default().to_string();
|
||||
s.edit("Add Marker", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
if l.markers_locked {
|
||||
continue;
|
||||
}
|
||||
// Layer markers are stored in layer time.
|
||||
let lt = l.layer_time(t);
|
||||
l.markers.retain(|m| m.time != lt);
|
||||
l.markers.push(Marker { time: lt, comment: comment.clone(), ..Default::default() });
|
||||
l.markers.sort_by_key(|m| m.time);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn markers_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let v = b_p(p, "value");
|
||||
let r = s.edit("Lock Markers", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked));
|
||||
for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
l.markers_locked = target;
|
||||
}
|
||||
Ok(target)
|
||||
})?;
|
||||
Ok(json!(r))
|
||||
}
|
||||
|
||||
fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Delete All Markers", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) {
|
||||
l.markers.clear();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- track matte
|
||||
|
||||
fn track_matte_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string();
|
||||
let kind = match op.as_str() {
|
||||
"alpha" => Some(MatteKind::Alpha),
|
||||
"alphaInverted" => Some(MatteKind::AlphaInverted),
|
||||
"luma" => Some(MatteKind::Luma),
|
||||
"lumaInverted" => Some(MatteKind::LumaInverted),
|
||||
"none" | "above" | "below" => None,
|
||||
o => return Err(bad("layer.trackMatte", format!("op: none|alpha|alphaInverted|luma|lumaInverted|above|below, not `{o}`"))),
|
||||
};
|
||||
s.edit("Track Matte", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let order: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
|
||||
let mut hide = vec![];
|
||||
for lid in &ids {
|
||||
let i = order.iter().position(|x| x == lid).unwrap_or(0);
|
||||
let l = c.layer_mut(*lid).ok_or(EngineError::NoComp)?;
|
||||
let cur_kind = l.track_matte.map(|m| m.kind).unwrap_or_default();
|
||||
l.track_matte = match op.as_str() {
|
||||
"none" => None,
|
||||
"above" | "below" => {
|
||||
let j = if op == "above" { i.checked_sub(1) } else { Some(i + 1) };
|
||||
match j.and_then(|j| order.get(j)) {
|
||||
Some(m) => Some(TrackMatte { layer: *m, kind: cur_kind }),
|
||||
None => return Err(bad("layer.trackMatte", format!("there is no layer {op}"))),
|
||||
}
|
||||
}
|
||||
_ => match (l.track_matte, i.checked_sub(1).and_then(|j| order.get(j))) {
|
||||
(Some(m), _) => Some(TrackMatte { layer: m.layer, kind: kind.unwrap_or_default() }),
|
||||
(None, Some(above)) => Some(TrackMatte { layer: *above, kind: kind.unwrap_or_default() }),
|
||||
(None, None) => return Err(bad("layer.trackMatte", "the top layer has no layer above to use as a matte")),
|
||||
},
|
||||
};
|
||||
if let Some(m) = l.track_matte {
|
||||
hide.push(m.layer);
|
||||
}
|
||||
}
|
||||
for m in hide {
|
||||
if let Some(ml) = c.layer_mut(m) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- open / reveal
|
||||
|
||||
fn source_item(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.source.item()).ok_or_else(|| bad(cmd, "the layer has no source item"))
|
||||
}
|
||||
|
||||
fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.openSource")?;
|
||||
if s.project.comp(item).is_some() {
|
||||
s.open_comp(item);
|
||||
return Ok(json!({"comp": item.0}));
|
||||
}
|
||||
frontend(s, "window.panel", &json!({"panel": "layer"}))
|
||||
}
|
||||
|
||||
/// Folder containing a file as a `file://` URL.
|
||||
pub(crate) fn folder_url(path: &str) -> String {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
format!("file://{dir}")
|
||||
}
|
||||
|
||||
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.revealInFinder")?;
|
||||
let path = match s.project.item(item).map(|i| &i.kind) {
|
||||
Some(effectcraft_project::ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
|
||||
_ => return Err(bad("layer.revealInFinder", "the layer's source is not a file")),
|
||||
};
|
||||
let url = folder_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
||||
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.revealSource")?;
|
||||
s.state.project_selection = vec![item];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": item.0}))
|
||||
}
|
||||
|
||||
fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.project_selection = vec![cid];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": cid.0}))
|
||||
}
|
||||
|
||||
fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let t = s.time();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut errors = vec![];
|
||||
for l in &comp.layers {
|
||||
let mut has = vec![];
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if pr.has_expression() {
|
||||
has.push(pr);
|
||||
}
|
||||
});
|
||||
for pr in has {
|
||||
let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default();
|
||||
let err = match &s.expr {
|
||||
Some(host) => {
|
||||
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
ctx.expr = Some(host.as_ref());
|
||||
host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let Some(e) = err {
|
||||
errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let props: Vec<Value> = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect();
|
||||
frontend(s, "timeline.revealProps", &json!({"props": props}))?;
|
||||
if errors.is_empty() {
|
||||
s.toast("No expression errors");
|
||||
}
|
||||
Ok(json!({"errors": errors}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sequence layers
|
||||
|
||||
fn sequence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
// Selection order (AE sequences in the order layers were selected).
|
||||
let ids: Vec<LayerId> = match p.get("layers") {
|
||||
Some(_) => layers_p(s, p)?.1,
|
||||
None => s.state.selected_layers.clone(),
|
||||
};
|
||||
if ids.len() < 2 {
|
||||
return Err(bad("layer.sequence", "select two or more layers"));
|
||||
}
|
||||
let overlap = b_p(p, "overlap").unwrap_or(false);
|
||||
let dur = if overlap { Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(1.0)) } else { Tick::ZERO };
|
||||
let transition = str_p(p, "transition").unwrap_or("off").to_string();
|
||||
s.edit("Sequence Layers", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut next: Option<Tick> = None;
|
||||
for (k, lid) in ids.iter().enumerate() {
|
||||
let l = c.layer_mut(*lid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(at) = next {
|
||||
let d = at - l.in_point;
|
||||
l.start_time += d;
|
||||
l.in_point += d;
|
||||
l.out_point += d;
|
||||
}
|
||||
if overlap && transition != "off" {
|
||||
let (a, b) = (l.layer_time(l.in_point), l.layer_time(l.in_point + dur));
|
||||
let (oa, ob) = (l.layer_time(l.out_point - dur), l.layer_time(l.out_point));
|
||||
let last = k + 1 == ids.len();
|
||||
if let Some(op) = l.transform_mut().and_then(|tr| tr.get_mut("opacity")) {
|
||||
op.keys.clear();
|
||||
if k > 0 {
|
||||
op.keys.push(Keyframe::new(a, KV::Scalar(0.0)));
|
||||
op.keys.push(Keyframe::new(b, KV::Scalar(100.0)));
|
||||
}
|
||||
if transition == "crossDissolve" && !last {
|
||||
op.keys.push(Keyframe::new(oa, KV::Scalar(100.0)));
|
||||
op.keys.push(Keyframe::new(ob, KV::Scalar(0.0)));
|
||||
}
|
||||
op.keys.sort_by_key(|k| k.time);
|
||||
op.keys.dedup_by_key(|k| k.time);
|
||||
}
|
||||
}
|
||||
next = Some(l.out_point - dur);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.quality", "Quality", [], None, "{layers?, quality: best|draft|wireframe}", has_layers, quality),
|
||||
cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic}", has_layers, sampling),
|
||||
cmd!("layer.frameBlending", "Frame Blending", [], None, "{layers?, mode: off|frameMix|pixelMotion}", has_layers, frame_blending),
|
||||
cmd!("layer.hideOtherVideo", "Hide Other Video", ["Layer", "Switches"], Some("Cmd+Alt+Shift+V"), "{layers?}", has_layers, hide_other_video),
|
||||
cmd!("layer.showAllVideo", "Show All Video", ["Layer", "Switches"], None, "{comp?}", has_comp, show_all_video),
|
||||
cmd!("layer.unlockAll", "Unlock All Layers", ["Layer", "Switches"], Some("Cmd+Shift+L"), "{comp?}", has_comp, unlock_all),
|
||||
cmd!("layer.expressions", "Enable/Disable Expressions", [], None, "{layers?, enabled: bool}", has_layers, expressions),
|
||||
cmd!(
|
||||
"layer.setTransform",
|
||||
"Transform Value",
|
||||
[],
|
||||
None,
|
||||
"{layers?, prop: anchor|position|scale|orientation|rotation|opacity, value}",
|
||||
has_layers,
|
||||
set_transform
|
||||
),
|
||||
cmd!(
|
||||
"layer.centerAnchor",
|
||||
"Center Anchor Point in Layer Content",
|
||||
["Layer", "Transform"],
|
||||
Some("Cmd+Alt+Home"),
|
||||
"{layers?}",
|
||||
has_layers,
|
||||
center_anchor
|
||||
),
|
||||
cmd!(
|
||||
"layer.mask.shape",
|
||||
"Mask Shape...",
|
||||
["Layer", "Mask"],
|
||||
Some("Cmd+Shift+M"),
|
||||
"{layer?, mask?, rect: [x, y, w, h], shape?: rect|ellipse}",
|
||||
has_masks,
|
||||
mask_shape
|
||||
),
|
||||
cmd!("layer.mask.set", "Mask Settings", [], None, "{layer?, mask?, field: feather|opacity|expansion, value}", has_masks, mask_set),
|
||||
cmd!("layer.mask.reset", "Reset Mask", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_reset),
|
||||
cmd!("layer.mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_remove),
|
||||
cmd!("layer.mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layers?}", has_masks, mask_remove_all),
|
||||
cmd!("layer.mask.mode", "Mask Mode", [], None, "{layer?, mask?, mode: None|Add|Subtract|Intersect|Lighten|Darken|Difference}", has_masks, mask_mode),
|
||||
cmd!("layer.mask.invert", "Inverted", ["Layer", "Mask"], Some("Cmd+Shift+I"), "{layer?, mask?, value?}", has_masks, mask_invert),
|
||||
cmd!("layer.mask.lock", "Locked", ["Layer", "Mask"], None, "{layer?, mask?, value?}", has_masks, mask_lock),
|
||||
cmd!("layer.mask.unlockAll", "Unlock All Masks", ["Layer", "Mask"], None, "{layers?}", has_masks, mask_unlock_all),
|
||||
cmd!("layer.mask.lockOthers", "Lock Other Masks", ["Layer", "Mask"], None, "{layer?, mask?}", has_masks, mask_lock_others),
|
||||
cmd!("layer.addMarker", "Add Marker", ["Layer", "Markers"], None, "{layers?, time?, comment?}", has_layers, add_marker),
|
||||
cmd!("layer.markersLock", "Lock Markers", ["Layer", "Markers"], None, "{layers?, value?}", has_layers, markers_lock),
|
||||
cmd!("layer.deleteAllMarkers", "Delete All Markers", ["Layer", "Markers"], None, "{layers?}", has_markers, delete_all_markers),
|
||||
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
|
||||
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
|
||||
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
|
||||
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
|
||||
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
|
||||
cmd!(
|
||||
"layer.sequence",
|
||||
"Sequence Layers...",
|
||||
["Animation", "Keyframe Assistant"],
|
||||
None,
|
||||
"{layers? (in order), overlap?: bool, duration? (s), transition?: off|dissolveFront|crossDissolve}",
|
||||
has_multiple_layers,
|
||||
sequence
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
|||
(l.id, at.source_time(l).seconds())
|
||||
})
|
||||
.collect();
|
||||
s.edit(if last { "Freeze on Last Frame" } else { "Freeze Frame" }, None, |proj, _| {
|
||||
s.edit(if last { "Freeze On Last Frame" } else { "Freeze Frame" }, None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for (lid, src) in &now {
|
||||
|
|
@ -169,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!(
|
||||
"layer.timeStretch",
|
||||
"Time Stretch…",
|
||||
"Time Stretch...",
|
||||
["Layer", "Time"],
|
||||
None,
|
||||
"{layers?, percent?|duration? (s), hold?: in|current|out, op?: reverse}",
|
||||
|
|
@ -183,6 +183,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
cmd!("layer.enableTimeRemap", "Enable Time Remapping", ["Layer", "Time"], Some("Cmd+Alt+T"), "{layers?, value?}", has_layers, enable_time_remap),
|
||||
cmd!("layer.freezeFrame", "Freeze Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, false)),
|
||||
cmd!("layer.freezeOnLastFrame", "Freeze on Last Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, true)),
|
||||
cmd!("layer.freezeOnLastFrame", "Freeze On Last Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, true)),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,29 @@
|
|||
//! shortcut and viewer/timeline gesture maps to one of these.
|
||||
|
||||
mod anim;
|
||||
mod animation;
|
||||
mod comp;
|
||||
mod comp_more;
|
||||
mod edit;
|
||||
mod effect;
|
||||
mod file;
|
||||
mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
mod mask;
|
||||
mod prop;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
mod three_d;
|
||||
mod time;
|
||||
mod view;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -89,6 +96,13 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(render_queue::specs());
|
||||
v.extend(help::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(comp_more::specs());
|
||||
v.extend(frontend::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
@ -178,6 +192,60 @@ pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> {
|
|||
if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Disabled-command enablement (menu entries whose implementation lands with another milestone).
|
||||
pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> {
|
||||
Err("not available yet in EffectCraft".into())
|
||||
}
|
||||
|
||||
/// `run` of a not-yet-available command.
|
||||
pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(EngineError::Other("not available yet in EffectCraft".into()))
|
||||
}
|
||||
|
||||
/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]).
|
||||
pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() });
|
||||
Ok(serde_json::json!({"frontend": id}))
|
||||
}
|
||||
|
||||
/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n`
|
||||
/// occurrences) so it can be found again on another layer.
|
||||
pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option<String> {
|
||||
for c in &g.children {
|
||||
let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1;
|
||||
let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) };
|
||||
if c.uid() == uid {
|
||||
return Some(seg);
|
||||
}
|
||||
if let effectcraft_project::Node::Group(sub) = c
|
||||
&& let Some(rest) = match_path_of(sub, uid)
|
||||
{
|
||||
return Some(format!("{seg}/{rest}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to
|
||||
/// their properties.
|
||||
pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> {
|
||||
let Some(comp) = s.active_comp() else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.props.find(*uid).is_some() {
|
||||
out.push((*lid, *uid));
|
||||
} else if let Some(g) = l.props.find_group(*uid) {
|
||||
g.walk("", &mut |_, p| out.push((*lid, p.uid)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- params ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
|
|
|||
|
|
@ -590,7 +590,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
|
||||
cmd!(
|
||||
"keys.interpolation",
|
||||
"Keyframe Interpolation…",
|
||||
"Keyframe Interpolation...",
|
||||
["Animation"],
|
||||
Some("Cmd+Alt+K"),
|
||||
"{interpolation?: linear|bezier|continuousBezier|autoBezier|hold, in?|out?: linear|bezier|hold, autoBezier?, continuous?, spatial?: linear|bezier|continuousBezier|autoBezier, roving?}",
|
||||
|
|
@ -599,7 +599,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"keys.velocity",
|
||||
"Keyframe Velocity…",
|
||||
"Keyframe Velocity...",
|
||||
["Animation"],
|
||||
Some("Cmd+Shift+K"),
|
||||
"{inSpeed?, inInfluence? %, outSpeed?, outInfluence? % (numbers or per-dimension arrays), continuous?}",
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("renderQueue.setRender", "Render Queue: Render Checkbox", [], None, "{item?|index?, render?: bool (toggles)}", has_items, set_render),
|
||||
cmd!(
|
||||
"renderQueue.setRenderSettings",
|
||||
"Render Settings…",
|
||||
"Render Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
|
||||
|
|
@ -402,7 +402,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"renderQueue.setOutputModule",
|
||||
"Output Module Settings…",
|
||||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, format?, channels?: rgb|rgba, quality?: 1-100, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool, output?}",
|
||||
|
|
@ -411,7 +411,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"renderQueue.setOutput",
|
||||
"Output To…",
|
||||
"Output To...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, path: file path or template like [compName].[fileExtension]}",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
//! Menu entries whose implementation is owned by another milestone (3D, render queue/export,
|
||||
//! time remapping, pen/mask-vertex editing, motion tracking, layer styles…) or that have no
|
||||
//! EffectCraft equivalent yet. They are registered so the menu bar matches After Effects, and are
|
||||
//! always disabled. When a feature lands, delete its line here and register the real command
|
||||
//! with the same id (the menu tree in `menus.rs` already points at it).
|
||||
|
||||
use super::{CommandSpec, not_yet, not_yet_run};
|
||||
|
||||
macro_rules! stub {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// macOS application menu items handled by the OS.
|
||||
stub!("app.hideOthers", "Hide Others", [], "{}"),
|
||||
stub!("app.showAll", "Show All", [], "{}"),
|
||||
// Render queue / export (export milestone).
|
||||
stub!("render.addOutputModule", "Add Output Module", ["Composition"], "{}"),
|
||||
stub!("render.preRender", "Pre-render...", ["Composition"], "{}"),
|
||||
stub!("render.saveCurrentPreview", "Save Current Preview...", ["Composition"], "{path}"),
|
||||
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
|
||||
// Proxies (no proxy model yet).
|
||||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
// Text editing in the viewer.
|
||||
stub!("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", ["Edit"], "{}"),
|
||||
stub!("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", ["Edit"], "{}"),
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"),
|
||||
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
|
||||
stub!("layer.mask.motionBlur", "Motion Blur", [], "{mode: sameAsLayer|on|off}"),
|
||||
stub!("layer.mask.featherFalloff", "Feather Falloff", [], "{mode: smooth|linear}"),
|
||||
stub!("layer.mask.hideLocked", "Hide Locked Masks", ["Layer", "Mask"], "{}"),
|
||||
stub!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], "{}"),
|
||||
stub!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], "{}"),
|
||||
stub!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], "Cmd+G", "{}"),
|
||||
stub!("path.ungroupShapes", "Ungroup Shapes", ["Layer", "Mask and Shape Path"], "Cmd+Shift+G", "{}"),
|
||||
stub!("path.setFirstVertex", "Set First Vertex", ["Layer", "Mask and Shape Path"], "{}"),
|
||||
stub!("path.freeTransform", "Free Transform Points", ["Layer", "Mask and Shape Path"], "{}"),
|
||||
// Time (timeline milestone).
|
||||
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
|
||||
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
|
||||
stub!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], "{}"),
|
||||
stub!("layer.create", "Create", [], "{op}"),
|
||||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
// Cameras / lights / materials / 3D views (3D milestone).
|
||||
stub!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
||||
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
|
||||
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
|
||||
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
|
||||
stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"),
|
||||
stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"),
|
||||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.layout", "Switch View Layout", [], "{views: 1|2|4}"),
|
||||
stub!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.lookAtSelected", "Look at Selected Layers", ["View"], "Cmd+Alt+Shift+\\", "{}"),
|
||||
stub!("view.lookAtAll", "Look at All Layers", ["View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
// Keyframes / text / tracking.
|
||||
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("text.animatorFontAxes", "Variable Font Axes", ["Animation", "Animate Text"], "{}"),
|
||||
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
|
||||
stub!("track.warpStabilizer", "Warp Stabilizer VFX", ["Animation"], "{}"),
|
||||
stub!("track.motion", "Track Motion", ["Animation"], "{}"),
|
||||
stub!("track.mask", "Track Mask", ["Animation"], "{}"),
|
||||
stub!("track.property", "Track this Property", ["Animation"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.saveWorkspace", "Save Changes to this Workspace", ["Window", "Workspace"], "{}"),
|
||||
stub!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], "{name}"),
|
||||
stub!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], "{}"),
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"animator": auid}))
|
||||
}
|
||||
|
||||
fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.addTextSelector";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let kind = str_p(p, "kind").unwrap_or("range").to_ascii_lowercase();
|
||||
|
|
@ -404,15 +404,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_animator,
|
||||
add_selector
|
||||
),
|
||||
cmd!(
|
||||
"layer.enablePerChar3D",
|
||||
"Enable Per-character 3D",
|
||||
["Animation", "Animate Text"],
|
||||
None,
|
||||
"{layer?, enabled?: bool, toggle?: bool}",
|
||||
has_text_layer,
|
||||
enable_per_char
|
||||
),
|
||||
cmd!("layer.enablePerChar3D", "Enable Per-character 3D", [], None, "{layer?, enabled?: bool, toggle?: bool}", has_text_layer, enable_per_char),
|
||||
cmd!(
|
||||
"layer.applyTextPreset",
|
||||
"Apply Text Animation Preset",
|
||||
|
|
|
|||
|
|
@ -533,7 +533,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!(
|
||||
"layer.autoOrient",
|
||||
"Auto-Orient…",
|
||||
"Auto-Orient...",
|
||||
["Layer", "Transform"],
|
||||
Some("Cmd+Alt+O"),
|
||||
"{mode: off|alongPath|towardsCamera|towardsPointOfInterest, layers?}",
|
||||
|
|
@ -542,7 +542,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"layer.newLight",
|
||||
"Light…",
|
||||
"Light...",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+L"),
|
||||
"{kind?: Parallel|Spot|Point|Ambient (default Spot), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
|
||||
|
|
@ -551,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"layer.newCamera",
|
||||
"Camera…",
|
||||
"Camera...",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+C"),
|
||||
"{name?, type?: oneNode|twoNode (default twoNode), preset?: 15mm|20mm|24mm|28mm|35mm|50mm|80mm|135mm|200mm, zoom?, focalLength? mm, angleOfView? deg, dof?, focusDistance?, aperture?, fStop?, blurLevel?, lockToZoom?, position? [x,y,z], poi? [x,y,z]}",
|
||||
|
|
@ -560,8 +560,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"layer.cameraSettings",
|
||||
"Camera Settings…",
|
||||
["Layer"],
|
||||
"Camera Settings...",
|
||||
["Layer", "Camera"],
|
||||
None,
|
||||
"{layer?, name?, type?, preset?, zoom?, focalLength?, angleOfView?, dof?, focusDistance?, aperture?, fStop?, blurLevel?, position?, poi?}",
|
||||
has_comp,
|
||||
|
|
@ -569,8 +569,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!(
|
||||
"layer.lightSettings",
|
||||
"Light Settings…",
|
||||
["Layer"],
|
||||
"Light Settings...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, name?, kind?, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
|
||||
has_comp,
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("time.set", "Go to Time…", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
|
||||
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
|
||||
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
//! View menu commands that change the project (guides) or headless-relevant editor state (the
|
||||
//! region of interest).
|
||||
|
||||
use effectcraft_project::Guide;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_guides(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.active_comp().is_some_and(|c| !c.guides.is_empty()) { Ok(()) } else { Err("the composition has no guides".into()) }
|
||||
}
|
||||
|
||||
fn add_guide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let vertical = match str_p(p, "orientation").unwrap_or("vertical") {
|
||||
"vertical" | "v" => true,
|
||||
"horizontal" | "h" => false,
|
||||
o => return Err(bad("view.addGuide", format!("orientation must be vertical or horizontal, not `{o}`"))),
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let position = f_p(p, "position").unwrap_or(if vertical { comp.width as f64 / 2.0 } else { comp.height as f64 / 2.0 });
|
||||
let n = s.edit("Add Guide", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.guides.push(Guide { vertical, position });
|
||||
Ok(c.guides.len())
|
||||
})?;
|
||||
Ok(json!({"guides": n}))
|
||||
}
|
||||
|
||||
fn clear_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.edit("Clear Guides", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn export_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("view.exportGuides", "missing `path`"))?;
|
||||
let comp = s.project.comp(comp_id(s, p)?).ok_or(EngineError::NoComp)?;
|
||||
let doc = json!({"effectcraftGuides": 1, "width": comp.width, "height": comp.height, "guides": comp.guides});
|
||||
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
|
||||
s.services.write_file(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "guides": comp.guides.len()}))
|
||||
}
|
||||
|
||||
fn import_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("view.importGuides", "missing `path`"))?;
|
||||
let cid = comp_id(s, p)?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let doc: Value = serde_json::from_slice(&bytes).map_err(|e| bad("view.importGuides", format!("not a guides file: {e}")))?;
|
||||
let guides: Vec<Guide> = serde_json::from_value(doc.get("guides").cloned().unwrap_or(json!([]))).map_err(|e| bad("view.importGuides", e.to_string()))?;
|
||||
let n = guides.len();
|
||||
s.edit("Import Guides", None, |proj, _| {
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.extend(guides);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"imported": n}))
|
||||
}
|
||||
|
||||
fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.region_of_interest = match p.get("rect") {
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let r = [0, 1, 2, 3].map(|i| a[i].as_f64().unwrap_or(0.0));
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
return Err(bad("view.setRegionOfInterest", "width and height must be positive"));
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(json!(s.state.region_of_interest))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
|
||||
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
|
||||
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
|
||||
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
|
||||
cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest),
|
||||
]
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
pub mod commands;
|
||||
pub mod demo;
|
||||
pub mod links;
|
||||
pub mod menus;
|
||||
pub mod render_queue;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -139,18 +140,50 @@ pub struct EditorState {
|
|||
pub snapping: bool,
|
||||
/// Last applied effect id (Effect ▸ last effect).
|
||||
pub last_effect: Option<String>,
|
||||
/// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region
|
||||
/// of Interest).
|
||||
#[serde(default)]
|
||||
pub region_of_interest: Option<[f64; 4]>,
|
||||
/// Property-link clipboard (Edit ▸ Copy with Property Links / Copy Expression Only).
|
||||
#[serde(skip)]
|
||||
pub link_clipboard: Option<LinkClip>,
|
||||
/// File ▸ Interpret Footage ▸ Remember Interpretation.
|
||||
#[serde(skip)]
|
||||
pub interpretation: Option<effectcraft_project::Footage>,
|
||||
/// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras).
|
||||
#[serde(default)]
|
||||
pub views3d: std::collections::BTreeMap<ItemId, effectcraft_render::three_d::Views3D>,
|
||||
}
|
||||
|
||||
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkClip {
|
||||
/// Expressions that link back to the source properties: (match path, expression text).
|
||||
Links { relative: bool, links: Vec<(String, String)> },
|
||||
/// Expressions only: (match path, expression).
|
||||
Expressions(Vec<(String, effectcraft_project::Expression)>),
|
||||
}
|
||||
|
||||
/// Events for frontends (drained each frame).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub enum Event {
|
||||
ProjectChanged { revision: u64 },
|
||||
Toast { message: String, error: bool },
|
||||
ProjectChanged {
|
||||
revision: u64,
|
||||
},
|
||||
Toast {
|
||||
message: String,
|
||||
error: bool,
|
||||
},
|
||||
OpenComp(ItemId),
|
||||
OpenUrl(String),
|
||||
/// A frontend-only command (viewer zoom, panels, dialogs…) ran: the UI performs it. Headless
|
||||
/// sessions ignore these.
|
||||
Frontend {
|
||||
command: String,
|
||||
params: Value,
|
||||
},
|
||||
/// Drop cached frames / renders (Edit ▸ Purge).
|
||||
PurgeCaches,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
|
|
@ -421,6 +454,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_menu_cmds;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,944 @@
|
|||
//! The menu bar, laid out like After Effects' (same menus, order, submenus, separators and
|
||||
//! labels). Every entry runs a registered command (`commands::find`), optionally with bound
|
||||
//! parameters (e.g. `layer.setBlendMode {"mode":"Multiply"}`). Frontends render this tree; agents
|
||||
//! read it through `ui.menu.list` / the command registry.
|
||||
//!
|
||||
//! The tree is written in a small indented text format (two spaces per level):
|
||||
//!
|
||||
//! ```text
|
||||
//! Layer top-level menu
|
||||
//! Blending Mode submenu (has deeper lines below it)
|
||||
//! Multiply | layer.setBlendMode {"mode":"Multiply"}
|
||||
//! Next Blending Mode | layer.setBlendMode {"step":1} | Shift+=
|
||||
//! --- separator
|
||||
//! @effects the effect categories (Effect menu)
|
||||
//! [mac] Quit EffectCraft | app.quit only on macOS ([!mac] = everywhere else)
|
||||
//! ```
|
||||
//!
|
||||
//! An entry's shortcut is the command's default shortcut unless the line gives one (needed for
|
||||
//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media
|
||||
//! Encoder, Essential Graphics, Behance, Creative Cloud…) are intentionally absent.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::{self, CommandSpec};
|
||||
|
||||
/// One node of the menu tree.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum MenuNode {
|
||||
Item(MenuEntry),
|
||||
Separator,
|
||||
Submenu { label: String, children: Vec<MenuNode> },
|
||||
}
|
||||
|
||||
/// A clickable menu entry.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MenuEntry {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
/// Parameters bound to the entry (`null` = none).
|
||||
#[serde(skip_serializing_if = "Value::is_null")]
|
||||
pub params: Value,
|
||||
pub shortcut: Option<String>,
|
||||
}
|
||||
|
||||
impl MenuEntry {
|
||||
pub fn spec(&self) -> Option<&'static CommandSpec> {
|
||||
commands::find(&self.command)
|
||||
}
|
||||
pub fn params_or_empty(&self) -> Value {
|
||||
if self.params.is_null() { Value::Object(Default::default()) } else { self.params.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level menus in order (macOS has the application menu first).
|
||||
pub fn top_level() -> Vec<&'static str> {
|
||||
menu_bar()
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
MenuNode::Submenu { label, .. } => Some(label.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The menu bar for this platform.
|
||||
pub fn menu_bar() -> &'static [MenuNode] {
|
||||
static BAR: OnceLock<Vec<MenuNode>> = OnceLock::new();
|
||||
BAR.get_or_init(|| parse(TREE, cfg!(target_os = "macos")).expect("menu tree parses"))
|
||||
}
|
||||
|
||||
/// Every entry with its submenu path (`["Layer", "Blending Mode"]`), depth first.
|
||||
pub fn entries() -> Vec<(Vec<String>, &'static MenuEntry)> {
|
||||
fn rec(nodes: &'static [MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<String>, &'static MenuEntry)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Item(e) => out.push((path.clone(), e)),
|
||||
MenuNode::Separator => {}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
path.push(label.clone());
|
||||
rec(children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
rec(menu_bar(), &mut vec![], &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles
|
||||
/// (rulers, grid…) are answered by the frontend.
|
||||
pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
||||
let comp = s.active_comp()?;
|
||||
let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l));
|
||||
let pstr = |k: &str| params.get(k).and_then(Value::as_str);
|
||||
match command {
|
||||
"layer.setBlendMode" => {
|
||||
let want = effectcraft_color::BlendMode::from_name(pstr("mode")?)?;
|
||||
Some(layer?.blend_mode == want)
|
||||
}
|
||||
"layer.setSwitch" => {
|
||||
let l = layer?;
|
||||
let sw = &l.switches;
|
||||
Some(match pstr("switch")? {
|
||||
"shy" => sw.shy,
|
||||
"lock" => sw.locked,
|
||||
"audio" => sw.audio,
|
||||
"video" => sw.video,
|
||||
"solo" => sw.solo,
|
||||
"fx" => sw.effects,
|
||||
"collapse" => sw.collapse,
|
||||
"motionBlur" => sw.motion_blur,
|
||||
"adjustment" => sw.adjustment,
|
||||
"threeD" => sw.three_d,
|
||||
"guide" => sw.guide,
|
||||
"preserveTransparency" => l.preserve_transparency,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
"layer.quality" => {
|
||||
use effectcraft_project::Quality::*;
|
||||
let q = layer?.switches.quality;
|
||||
Some(matches!((pstr("quality")?, q), ("best", Best) | ("draft", Draft) | ("wireframe", Wireframe)))
|
||||
}
|
||||
"layer.sampling" => {
|
||||
use effectcraft_project::Sampling::*;
|
||||
let q = layer?.switches.sampling;
|
||||
Some(matches!((pstr("sampling")?, q), ("bilinear", Bilinear) | ("bicubic", Bicubic)))
|
||||
}
|
||||
"layer.frameBlending" => {
|
||||
use effectcraft_project::FrameBlend::*;
|
||||
let q = layer?.switches.frame_blend;
|
||||
Some(matches!((pstr("mode")?, q), ("off", Off) | ("frameMix", FrameMix) | ("pixelMotion", PixelMotion)))
|
||||
}
|
||||
"layer.trackMatte" => {
|
||||
use effectcraft_project::MatteKind::*;
|
||||
let m = layer?.track_matte;
|
||||
Some(match (pstr("op")?, m) {
|
||||
("none", None) => true,
|
||||
("alpha", Some(m)) => m.kind == Alpha,
|
||||
("alphaInverted", Some(m)) => m.kind == AlphaInverted,
|
||||
("luma", Some(m)) => m.kind == Luma,
|
||||
("lumaInverted", Some(m)) => m.kind == LumaInverted,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
"layer.markersLock" => Some(layer?.markers_locked),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the tree text. `mac` selects `[mac]` / `[!mac]` lines.
|
||||
pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
||||
struct Line<'a> {
|
||||
depth: usize,
|
||||
body: &'a str,
|
||||
}
|
||||
let mut lines = vec![];
|
||||
// Depth of a skipped (other-platform) line: its whole subtree is skipped too.
|
||||
let mut skip_below: Option<usize> = None;
|
||||
for (n, raw) in text.lines().enumerate() {
|
||||
if raw.trim().is_empty() || raw.trim_start().starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let indent = raw.len() - raw.trim_start().len();
|
||||
if indent % 2 != 0 {
|
||||
return Err(format!("line {}: odd indentation", n + 1));
|
||||
}
|
||||
let depth = indent / 2;
|
||||
match skip_below {
|
||||
Some(d) if depth > d => continue,
|
||||
_ => skip_below = None,
|
||||
}
|
||||
let mut body = raw.trim();
|
||||
let tagged = |tag: &str, keep: bool, body: &mut &str| -> Option<bool> {
|
||||
let rest = body.strip_prefix(tag)?;
|
||||
*body = rest;
|
||||
Some(keep)
|
||||
};
|
||||
if let Some(false) = tagged("[mac] ", mac, &mut body).or_else(|| tagged("[!mac] ", !mac, &mut body)) {
|
||||
skip_below = Some(depth);
|
||||
continue;
|
||||
}
|
||||
lines.push(Line { depth, body });
|
||||
}
|
||||
fn build(lines: &[Line], i: &mut usize, depth: usize) -> Result<Vec<MenuNode>, String> {
|
||||
let mut out = vec![];
|
||||
while *i < lines.len() {
|
||||
let l = &lines[*i];
|
||||
if l.depth < depth {
|
||||
break;
|
||||
}
|
||||
if l.depth > depth {
|
||||
return Err(format!("unexpected indentation at `{}`", l.body));
|
||||
}
|
||||
*i += 1;
|
||||
if l.body == "---" {
|
||||
out.push(MenuNode::Separator);
|
||||
continue;
|
||||
}
|
||||
if l.body == "@effects" {
|
||||
out.extend(effect_categories());
|
||||
continue;
|
||||
}
|
||||
let mut parts = l.body.split(" | ");
|
||||
let label = parts.next().unwrap_or_default().trim().to_string();
|
||||
match parts.next() {
|
||||
Some(cmd) => {
|
||||
let cmd = cmd.trim();
|
||||
let (command, params) = match cmd.split_once(' ') {
|
||||
Some((c, json)) => (c.to_string(), serde_json::from_str(json.trim()).map_err(|e| format!("`{}`: {e}", l.body))?),
|
||||
None => (cmd.to_string(), Value::Null),
|
||||
};
|
||||
// Entries with bound params state their own shortcut; plain entries use the command's.
|
||||
let inherited = if params.is_null() { commands::find(&command).and_then(|c| c.shortcut).map(str::to_string) } else { None };
|
||||
let shortcut = parts.next().map(|s| s.trim().to_string()).or(inherited);
|
||||
out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut }));
|
||||
}
|
||||
None => {
|
||||
let children = build(lines, i, depth + 1)?;
|
||||
if children.is_empty() {
|
||||
return Err(format!("`{label}` has neither a command nor children"));
|
||||
}
|
||||
out.push(MenuNode::Submenu { label, children });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
let mut i = 0;
|
||||
build(&lines, &mut i, 0)
|
||||
}
|
||||
|
||||
/// Effect ▸ category submenus from the effect registry.
|
||||
fn effect_categories() -> Vec<MenuNode> {
|
||||
let mut cats: Vec<(&str, Vec<MenuNode>)> = vec![];
|
||||
for e in crate::effects::registry() {
|
||||
let entry =
|
||||
MenuNode::Item(MenuEntry { label: e.name.into(), command: "effect.apply".into(), params: serde_json::json!({"effect": e.id}), shortcut: None });
|
||||
match cats.iter_mut().find(|(c, _)| *c == e.category) {
|
||||
Some((_, v)) => v.push(entry),
|
||||
None => cats.push((e.category, vec![entry])),
|
||||
}
|
||||
}
|
||||
cats.sort_by_key(|c| c.0.to_ascii_lowercase());
|
||||
for (_, v) in &mut cats {
|
||||
v.sort_by_key(|n| match n {
|
||||
MenuNode::Item(e) => e.label.to_ascii_lowercase(),
|
||||
_ => String::new(),
|
||||
});
|
||||
}
|
||||
cats.into_iter().map(|(c, children)| MenuNode::Submenu { label: c.to_string(), children }).collect()
|
||||
}
|
||||
|
||||
/// The After Effects menu layout. Labels use "..." like AE's macOS menus.
|
||||
pub const TREE: &str = r#"
|
||||
[mac] EffectCraft
|
||||
About EffectCraft... | app.about
|
||||
---
|
||||
Settings...
|
||||
General... | app.settings {"page":"general"} | Cmd+Alt+;
|
||||
Startup & Repair... | app.settings {"page":"startup"}
|
||||
Project... | app.settings {"page":"project"}
|
||||
Composition... | app.settings {"page":"composition"}
|
||||
Previews... | app.settings {"page":"previews"}
|
||||
Appearance... | app.settings {"page":"appearance"}
|
||||
Grids & Guides... | app.settings {"page":"grids"}
|
||||
Labels... | app.settings {"page":"labels"}
|
||||
Type... | app.settings {"page":"type"}
|
||||
Import... | app.settings {"page":"import"}
|
||||
Export... | app.settings {"page":"export"}
|
||||
Audio... | app.settings {"page":"audio"}
|
||||
Disk... | app.settings {"page":"disk"}
|
||||
Memory & CPU... | app.settings {"page":"memory"}
|
||||
Video... | app.settings {"page":"video"}
|
||||
3D... | app.settings {"page":"3d"}
|
||||
Scripting & Expressions... | app.settings {"page":"scripting"}
|
||||
---
|
||||
Hide EffectCraft | app.hide
|
||||
Hide Others | app.hideOthers
|
||||
Show All | app.showAll
|
||||
---
|
||||
Quit EffectCraft | app.quit
|
||||
File
|
||||
New
|
||||
New Project | file.newProject
|
||||
New Folder | project.newFolder
|
||||
Open Project... | file.open
|
||||
---
|
||||
Close | file.close
|
||||
Close Project | file.closeProject
|
||||
Save | file.save
|
||||
Save As
|
||||
Save As... | file.saveAs
|
||||
Save a Copy... | file.saveCopy
|
||||
Increment and Save | file.incrementAndSave
|
||||
Revert | file.revert
|
||||
---
|
||||
Import
|
||||
File... | file.import
|
||||
Multiple Files... | file.importMultiple
|
||||
Placeholder... | file.importPlaceholder
|
||||
Solid... | file.importSolid
|
||||
Export
|
||||
Add to Render Queue | renderQueue.add
|
||||
---
|
||||
Find | app.find
|
||||
---
|
||||
Add Footage to Comp | layer.addItem
|
||||
New Comp from Selection... | file.newCompFromSelection
|
||||
---
|
||||
Dependencies
|
||||
Collect Files... | file.collectFiles
|
||||
Consolidate All Footage | file.consolidateFootage
|
||||
Remove Unused Footage | file.removeUnusedFootage
|
||||
Reduce Project | file.reduceProject
|
||||
Find Missing Effects | file.findMissing {"what":"effects"}
|
||||
Find Missing Fonts | file.findMissing {"what":"fonts"}
|
||||
Find Missing Footage | file.findMissing {"what":"footage"}
|
||||
---
|
||||
Scripts
|
||||
Run Script File... | file.runScript
|
||||
---
|
||||
Create Proxy
|
||||
Still... | file.createProxy {"kind":"still"}
|
||||
Movie... | file.createProxy {"kind":"movie"}
|
||||
Set Proxy
|
||||
File... | file.setProxy
|
||||
None | file.setProxyNone
|
||||
Interpret Footage
|
||||
Main... | file.interpretFootage
|
||||
Proxy... | file.interpretProxy
|
||||
Remember Interpretation | file.rememberInterpretation
|
||||
Apply Interpretation | file.applyInterpretation
|
||||
Replace Footage
|
||||
File... | file.replaceFootage
|
||||
Placeholder... | file.replaceWithPlaceholder
|
||||
Solid... | file.replaceWithSolid
|
||||
Reload Footage | file.reloadFootage
|
||||
Reveal in Finder | file.revealInFinder
|
||||
---
|
||||
Project Settings... | file.projectSettings
|
||||
[!mac] ---
|
||||
[!mac] Exit | app.quit
|
||||
Edit
|
||||
Undo | edit.undo
|
||||
Redo | edit.redo
|
||||
---
|
||||
Quick Apply... | app.commandPalette
|
||||
---
|
||||
Cut | edit.cut
|
||||
Copy | edit.copy
|
||||
Copy with Property Links | edit.copyWithPropertyLinks
|
||||
Copy with Relative Property Links | edit.copyWithRelativePropertyLinks
|
||||
Copy Expression Only | edit.copyExpressionOnly
|
||||
Paste | edit.paste
|
||||
Paste Reversed Keyframes | edit.pasteReversedKeyframes
|
||||
Paste Text and Match Formatting | edit.pasteTextMatchFormatting
|
||||
Paste Text Formatting Only | edit.pasteTextFormattingOnly
|
||||
Clear | edit.clear
|
||||
---
|
||||
Duplicate | edit.duplicate
|
||||
Split Layer | edit.splitLayer
|
||||
Lift Work Area | edit.liftWorkArea
|
||||
Extract Work Area | edit.extractWorkArea
|
||||
Select All | edit.selectAll
|
||||
Deselect All | edit.deselectAll
|
||||
---
|
||||
Label
|
||||
Select Label Group | edit.selectLabelGroup
|
||||
---
|
||||
None | edit.label {"label":"None"}
|
||||
Red | edit.label {"label":"Red"}
|
||||
Yellow | edit.label {"label":"Yellow"}
|
||||
Aqua | edit.label {"label":"Aqua"}
|
||||
Pink | edit.label {"label":"Pink"}
|
||||
Lavender | edit.label {"label":"Lavender"}
|
||||
Peach | edit.label {"label":"Peach"}
|
||||
Sea Foam | edit.label {"label":"Sea Foam"}
|
||||
Blue | edit.label {"label":"Blue"}
|
||||
Green | edit.label {"label":"Green"}
|
||||
Purple | edit.label {"label":"Purple"}
|
||||
Orange | edit.label {"label":"Orange"}
|
||||
Brown | edit.label {"label":"Brown"}
|
||||
Fuchsia | edit.label {"label":"Fuchsia"}
|
||||
Cyan | edit.label {"label":"Cyan"}
|
||||
Sandstone | edit.label {"label":"Sandstone"}
|
||||
Dark Green | edit.label {"label":"Dark Green"}
|
||||
---
|
||||
Edit Label Colors... | app.settings {"page":"labels"}
|
||||
Select Keyframe Label Group
|
||||
On Selected Layers | keys.selectLabelGroup {"scope":"selected"}
|
||||
On All Layers | keys.selectLabelGroup {"scope":"all"}
|
||||
Visible Keyframes on Selected Layers | keys.selectLabelGroup {"scope":"visibleSelected"}
|
||||
Visible Keyframes on All Layers | keys.selectLabelGroup {"scope":"visibleAll"}
|
||||
---
|
||||
Purge
|
||||
All Cache... | edit.purge {"what":"all"}
|
||||
All Memory & Disk Cache... | edit.purge {"what":"memoryAndDisk"}
|
||||
All Memory | edit.purge {"what":"memory"}
|
||||
All Disk Cache... | edit.purge {"what":"disk"}
|
||||
All 3D Cache... | edit.purge {"what":"3d"}
|
||||
Undo | edit.purgeUndo
|
||||
Image Cache Memory | edit.purge {"what":"image"}
|
||||
Snapshot | edit.purge {"what":"snapshot"}
|
||||
---
|
||||
Edit Original... | edit.editOriginal
|
||||
---
|
||||
Templates
|
||||
Render Settings... | app.templates {"kind":"renderSettings"}
|
||||
Output Module... | app.templates {"kind":"outputModule"}
|
||||
Keyboard Shortcuts | app.keyboardShortcuts
|
||||
[!mac] Preferences
|
||||
[!mac] General... | app.settings {"page":"general"} | Cmd+Alt+;
|
||||
[!mac] Startup & Repair... | app.settings {"page":"startup"}
|
||||
[!mac] Project... | app.settings {"page":"project"}
|
||||
[!mac] Composition... | app.settings {"page":"composition"}
|
||||
[!mac] Previews... | app.settings {"page":"previews"}
|
||||
[!mac] Appearance... | app.settings {"page":"appearance"}
|
||||
[!mac] Grids & Guides... | app.settings {"page":"grids"}
|
||||
[!mac] Labels... | app.settings {"page":"labels"}
|
||||
[!mac] Type... | app.settings {"page":"type"}
|
||||
[!mac] Import... | app.settings {"page":"import"}
|
||||
[!mac] Export... | app.settings {"page":"export"}
|
||||
[!mac] Audio... | app.settings {"page":"audio"}
|
||||
[!mac] Disk... | app.settings {"page":"disk"}
|
||||
[!mac] Memory & CPU... | app.settings {"page":"memory"}
|
||||
[!mac] Video... | app.settings {"page":"video"}
|
||||
[!mac] 3D... | app.settings {"page":"3d"}
|
||||
[!mac] Scripting & Expressions... | app.settings {"page":"scripting"}
|
||||
Composition
|
||||
New Composition... | comp.new
|
||||
---
|
||||
Composition Settings... | comp.settings
|
||||
Set Poster Time | comp.setPosterTime
|
||||
Trim Comp to Work Area | comp.trimToWorkArea
|
||||
Crop Comp to Region of Interest | comp.cropToRegionOfInterest
|
||||
Crop Comp to Selected Layer(s) Bounds | comp.cropToLayerBounds
|
||||
---
|
||||
Add to Render Queue | renderQueue.add
|
||||
Add Output Module | render.addOutputModule
|
||||
---
|
||||
Preview
|
||||
Play Current Preview | playback.toggle
|
||||
Cache Frames When Idle | playback.cacheWhenIdle
|
||||
Audio | playback.audio
|
||||
Save Frame As
|
||||
File... | comp.saveFrameAs
|
||||
Pre-render... | render.preRender
|
||||
Save Current Preview... | render.saveCurrentPreview
|
||||
---
|
||||
Responsive Design — Time
|
||||
Create Intro | comp.responsiveTime {"op":"intro"}
|
||||
Create Outro | comp.responsiveTime {"op":"outro"}
|
||||
Create Protected Region from Work Area | comp.responsiveTime {"op":"workArea"}
|
||||
---
|
||||
Composition Flowchart | comp.flowchart
|
||||
Composition Mini-Flowchart | comp.miniFlowchart
|
||||
Layer
|
||||
New
|
||||
Text | layer.newText
|
||||
Solid... | layer.newSolid
|
||||
Light... | layer.newLight
|
||||
Camera... | layer.newCamera
|
||||
Null Object | layer.newNull
|
||||
Shape Layer | layer.newShape
|
||||
Adjustment Layer | layer.newAdjustment
|
||||
Content-Aware Fill Layer... | layer.newContentAwareFill
|
||||
Cube | layer.new3dPrimitive {"kind":"cube"}
|
||||
Sphere | layer.new3dPrimitive {"kind":"sphere"}
|
||||
Plane | layer.new3dPrimitive {"kind":"plane"}
|
||||
Torus | layer.new3dPrimitive {"kind":"torus"}
|
||||
Cone | layer.new3dPrimitive {"kind":"cone"}
|
||||
Cylinder | layer.new3dPrimitive {"kind":"cylinder"}
|
||||
Layer Settings... | layer.settings
|
||||
---
|
||||
Open Layer | layer.openLayer
|
||||
Open Layer Source | layer.openSource
|
||||
Reveal in Finder | layer.revealInFinder
|
||||
---
|
||||
Mask
|
||||
New Mask | layer.addMask
|
||||
Mask Shape... | layer.mask.shape
|
||||
Mask Feather... | layer.mask.set {"field":"feather"} | Cmd+Shift+F
|
||||
Mask Opacity... | layer.mask.set {"field":"opacity"}
|
||||
Mask Expansion... | layer.mask.set {"field":"expansion"}
|
||||
Reset Mask | layer.mask.reset
|
||||
Remove Mask | layer.mask.remove
|
||||
Remove All Masks | layer.mask.removeAll
|
||||
Mode
|
||||
None | layer.mask.mode {"mode":"None"}
|
||||
Add | layer.mask.mode {"mode":"Add"}
|
||||
Subtract | layer.mask.mode {"mode":"Subtract"}
|
||||
Intersect | layer.mask.mode {"mode":"Intersect"}
|
||||
Lighten | layer.mask.mode {"mode":"Lighten"}
|
||||
Darken | layer.mask.mode {"mode":"Darken"}
|
||||
Difference | layer.mask.mode {"mode":"Difference"}
|
||||
Inverted | layer.mask.invert
|
||||
Locked | layer.mask.lock
|
||||
Motion Blur
|
||||
Same As Layer | layer.mask.motionBlur {"mode":"sameAsLayer"}
|
||||
On | layer.mask.motionBlur {"mode":"on"}
|
||||
Off | layer.mask.motionBlur {"mode":"off"}
|
||||
Feather Falloff
|
||||
Smooth | layer.mask.featherFalloff {"mode":"smooth"}
|
||||
Linear | layer.mask.featherFalloff {"mode":"linear"}
|
||||
Unlock All Masks | layer.mask.unlockAll
|
||||
Lock Other Masks | layer.mask.lockOthers
|
||||
Hide Locked Masks | layer.mask.hideLocked
|
||||
Mask and Shape Path
|
||||
RotoBezier | path.rotoBezier
|
||||
Closed | mask.setClosed
|
||||
Convert To Bezier Path | path.convertToBezier
|
||||
Group Shapes | path.groupShapes
|
||||
Ungroup Shapes | path.ungroupShapes
|
||||
Set First Vertex | path.setFirstVertex
|
||||
Free Transform Points | path.freeTransform
|
||||
Quality
|
||||
Best | layer.quality {"quality":"best"} | Cmd+U
|
||||
Draft | layer.quality {"quality":"draft"} | Cmd+Shift+U
|
||||
Wireframe | layer.quality {"quality":"wireframe"} | Cmd+Alt+Shift+U
|
||||
---
|
||||
Bilinear | layer.sampling {"sampling":"bilinear"}
|
||||
Bicubic | layer.sampling {"sampling":"bicubic"}
|
||||
Switches
|
||||
Hide Other Video | layer.hideOtherVideo
|
||||
Show All Video | layer.showAllVideo
|
||||
Unlock All Layers | layer.unlockAll
|
||||
Enable Expressions | layer.expressions {"enabled":true}
|
||||
Disable Expressions | layer.expressions {"enabled":false}
|
||||
---
|
||||
Shy | layer.setSwitch {"switch":"shy"}
|
||||
Lock | layer.setSwitch {"switch":"lock"} | Cmd+L
|
||||
Audio | layer.setSwitch {"switch":"audio"}
|
||||
Video | layer.setSwitch {"switch":"video"}
|
||||
Solo | layer.setSwitch {"switch":"solo"}
|
||||
Effect | layer.setSwitch {"switch":"fx"}
|
||||
Collapse | layer.setSwitch {"switch":"collapse"}
|
||||
Motion Blur | layer.setSwitch {"switch":"motionBlur"}
|
||||
Adjustment Layer | layer.setSwitch {"switch":"adjustment"}
|
||||
Transform
|
||||
Reset | layer.transform {"op":"reset"}
|
||||
Anchor Point... | layer.setTransform {"prop":"anchor"}
|
||||
Position... | layer.setTransform {"prop":"position"} | Cmd+Shift+P
|
||||
Scale... | layer.setTransform {"prop":"scale"}
|
||||
Orientation... | layer.setTransform {"prop":"orientation"} | Cmd+Alt+Shift+R
|
||||
Rotation... | layer.setTransform {"prop":"rotation"} | Cmd+Shift+R
|
||||
Opacity... | layer.setTransform {"prop":"opacity"} | Cmd+Shift+O
|
||||
---
|
||||
Flip Horizontal | layer.transform {"op":"flipH"}
|
||||
Flip Vertical | layer.transform {"op":"flipV"}
|
||||
Center In View | layer.transform {"op":"center"}
|
||||
Center Anchor Point in Layer Content | layer.centerAnchor
|
||||
Fit to Comp | layer.transform {"op":"fit"} | Cmd+Alt+F
|
||||
Fit to Comp Width | layer.transform {"op":"fitWidth"} | Cmd+Alt+Shift+H
|
||||
Fit to Comp Height | layer.transform {"op":"fitHeight"} | Cmd+Alt+Shift+G
|
||||
---
|
||||
Auto-Orient... | layer.autoOrient
|
||||
Time
|
||||
Enable Time Remapping | layer.enableTimeRemap
|
||||
Time-Reverse Layer | layer.timeReverse
|
||||
Time Stretch... | layer.timeStretch
|
||||
Freeze Frame | layer.freezeFrame
|
||||
Freeze On Last Frame | layer.freezeOnLastFrame
|
||||
Align Video to Data | layer.alignVideoToData
|
||||
Frame Blending
|
||||
Off | layer.frameBlending {"mode":"off"}
|
||||
Frame Mix | layer.frameBlending {"mode":"frameMix"}
|
||||
Pixel Motion | layer.frameBlending {"mode":"pixelMotion"}
|
||||
3D Layer | layer.setSwitch {"switch":"threeD"}
|
||||
Guide Layer | layer.setSwitch {"switch":"guide"}
|
||||
Environment Layer | layer.environment
|
||||
Markers
|
||||
Add Marker | layer.addMarker
|
||||
Update Markers From Source | layer.updateMarkersFromSource
|
||||
Lock Markers | layer.markersLock
|
||||
Delete All Markers | layer.deleteAllMarkers
|
||||
---
|
||||
Preserve Transparency | layer.setSwitch {"switch":"preserveTransparency"}
|
||||
Blending Mode
|
||||
Normal | layer.setBlendMode {"mode":"Normal"}
|
||||
Dissolve | layer.setBlendMode {"mode":"Dissolve"}
|
||||
Dancing Dissolve | layer.setBlendMode {"mode":"Dancing Dissolve"}
|
||||
---
|
||||
Darken | layer.setBlendMode {"mode":"Darken"}
|
||||
Multiply | layer.setBlendMode {"mode":"Multiply"}
|
||||
Color Burn | layer.setBlendMode {"mode":"Color Burn"}
|
||||
Classic Color Burn | layer.setBlendMode {"mode":"Classic Color Burn"}
|
||||
Linear Burn | layer.setBlendMode {"mode":"Linear Burn"}
|
||||
Darker Color | layer.setBlendMode {"mode":"Darker Color"}
|
||||
---
|
||||
Add | layer.setBlendMode {"mode":"Add"}
|
||||
Lighten | layer.setBlendMode {"mode":"Lighten"}
|
||||
Screen | layer.setBlendMode {"mode":"Screen"}
|
||||
Color Dodge | layer.setBlendMode {"mode":"Color Dodge"}
|
||||
Classic Color Dodge | layer.setBlendMode {"mode":"Classic Color Dodge"}
|
||||
Linear Dodge | layer.setBlendMode {"mode":"Linear Dodge"}
|
||||
Lighter Color | layer.setBlendMode {"mode":"Lighter Color"}
|
||||
---
|
||||
Overlay | layer.setBlendMode {"mode":"Overlay"}
|
||||
Soft Light | layer.setBlendMode {"mode":"Soft Light"}
|
||||
Hard Light | layer.setBlendMode {"mode":"Hard Light"}
|
||||
Linear Light | layer.setBlendMode {"mode":"Linear Light"}
|
||||
Vivid Light | layer.setBlendMode {"mode":"Vivid Light"}
|
||||
Pin Light | layer.setBlendMode {"mode":"Pin Light"}
|
||||
Hard Mix | layer.setBlendMode {"mode":"Hard Mix"}
|
||||
---
|
||||
Difference | layer.setBlendMode {"mode":"Difference"}
|
||||
Classic Difference | layer.setBlendMode {"mode":"Classic Difference"}
|
||||
Exclusion | layer.setBlendMode {"mode":"Exclusion"}
|
||||
Subtract | layer.setBlendMode {"mode":"Subtract"}
|
||||
Divide | layer.setBlendMode {"mode":"Divide"}
|
||||
---
|
||||
Hue | layer.setBlendMode {"mode":"Hue"}
|
||||
Saturation | layer.setBlendMode {"mode":"Saturation"}
|
||||
Color | layer.setBlendMode {"mode":"Color"}
|
||||
Luminosity | layer.setBlendMode {"mode":"Luminosity"}
|
||||
---
|
||||
Stencil Alpha | layer.setBlendMode {"mode":"Stencil Alpha"}
|
||||
Stencil Luma | layer.setBlendMode {"mode":"Stencil Luma"}
|
||||
Silhouette Alpha | layer.setBlendMode {"mode":"Silhouette Alpha"}
|
||||
Silhouette Luma | layer.setBlendMode {"mode":"Silhouette Luma"}
|
||||
---
|
||||
Alpha Add | layer.setBlendMode {"mode":"Alpha Add"}
|
||||
Luminescent Premul | layer.setBlendMode {"mode":"Luminescent Premul"}
|
||||
Next Blending Mode | layer.setBlendMode {"step":1} | Shift+=
|
||||
Previous Blending Mode | layer.setBlendMode {"step":-1} | Shift+-
|
||||
Track Matte
|
||||
No Track Matte | layer.trackMatte {"op":"none"}
|
||||
Alpha Matte | layer.trackMatte {"op":"alpha"}
|
||||
Alpha Inverted Matte | layer.trackMatte {"op":"alphaInverted"}
|
||||
Luma Matte | layer.trackMatte {"op":"luma"}
|
||||
Luma Inverted Matte | layer.trackMatte {"op":"lumaInverted"}
|
||||
---
|
||||
Matte with Layer Above | layer.trackMatte {"op":"above"}
|
||||
Matte with Layer Below | layer.trackMatte {"op":"below"}
|
||||
Layer Styles
|
||||
Convert to Editable Styles | layer.style.convertToEditable
|
||||
Show All | layer.style.showAll
|
||||
Remove All | layer.style.removeAll
|
||||
---
|
||||
Drop Shadow | layer.style.dropShadow
|
||||
Inner Shadow | layer.style.innerShadow
|
||||
Outer Glow | layer.style.outerGlow
|
||||
Inner Glow | layer.style.innerGlow
|
||||
Bevel and Emboss | layer.style.bevelEmboss
|
||||
Satin | layer.style.satin
|
||||
Color Overlay | layer.style.colorOverlay
|
||||
Gradient Overlay | layer.style.gradientOverlay
|
||||
Stroke | layer.style.stroke
|
||||
---
|
||||
Arrange
|
||||
Bring Layer to Front | layer.arrange {"to":"front"} | Cmd+Shift+]
|
||||
Bring Layer Forward | layer.arrange {"to":"forward"} | Cmd+]
|
||||
Send Layer Backward | layer.arrange {"to":"backward"} | Cmd+[
|
||||
Send Layer to Back | layer.arrange {"to":"back"} | Cmd+Shift+[
|
||||
---
|
||||
Reveal
|
||||
Reveal in Finder | layer.revealInFinder
|
||||
Reveal Layer Source in Project | layer.revealSource
|
||||
Reveal Layer in Project Flowchart | comp.flowchart
|
||||
Reveal Composition in Project | comp.revealInProject
|
||||
Reveal Expression Errors | layer.revealExpressionErrors
|
||||
Create
|
||||
Convert to Editable Text | layer.create {"op":"editableText"}
|
||||
Create Shapes from Text | layer.create {"op":"shapesFromText"}
|
||||
Create Masks from Text | layer.create {"op":"masksFromText"}
|
||||
Create Shapes from Vector Layer | layer.create {"op":"shapesFromVector"}
|
||||
Create Keyframes from Data | layer.create {"op":"keyframesFromData"}
|
||||
Null Controllers for Positional Points | layer.create {"op":"nullsPositional"}
|
||||
Null Controllers for Path Points | layer.create {"op":"nullsPathPoints"}
|
||||
Nulls Following Path Points | layer.create {"op":"nullsFollowing"}
|
||||
Null Tracing Along Path | layer.create {"op":"nullTracing"}
|
||||
Create 3D Layer Instance | layer.create {"op":"instance3d"}
|
||||
---
|
||||
Camera
|
||||
Create Camera from 3D View | camera.fromView
|
||||
Create Stereo 3D Rig | camera.stereoRig
|
||||
Create Orbit Null | camera.orbitNull
|
||||
Create Cameras from 3D Model | camera.fromModel
|
||||
Link Focus Distance to Point of Interest | camera.linkFocusToPoi
|
||||
Link Focus Distance to Layer | camera.linkFocusToLayer
|
||||
Set Focus Distance to Layer | camera.setFocusToLayer
|
||||
Camera Settings... | layer.cameraSettings
|
||||
Reset 3D View | view.reset3DView
|
||||
Light
|
||||
Create Lights from 3D Model | light.fromModel
|
||||
Control Light with Camera | light.controlWithCamera
|
||||
Create Environment Light Background Layer | light.environmentBackground
|
||||
Material
|
||||
Reveal Material Source in Project | material.revealSource
|
||||
Reset Material | material.reset
|
||||
Duplicate and Assign Material | material.duplicateAssign
|
||||
Auto-trace... | layer.autoTrace
|
||||
Pre-compose... | layer.precompose
|
||||
Scene Edit Detection... | layer.sceneEditDetection
|
||||
Effect
|
||||
Effect Controls | window.panel {"panel":"effectControls"} | F3
|
||||
Last Effect | effect.applyLast
|
||||
Remove All | effect.removeAll
|
||||
---
|
||||
Manage Effects... | effect.manage
|
||||
---
|
||||
@effects
|
||||
Animation
|
||||
Save Animation Preset... | anim.savePreset
|
||||
Apply Animation Preset... | anim.applyPreset
|
||||
Browse Presets... | anim.browsePresets
|
||||
Text Animation Presets
|
||||
Typewriter | layer.applyTextPreset {"preset":"typewriter"}
|
||||
Fade Up Characters | layer.applyTextPreset {"preset":"fadeUpCharacters"}
|
||||
Bounce In Words | layer.applyTextPreset {"preset":"bounceInWords"}
|
||||
Tracking In | layer.applyTextPreset {"preset":"trackingIn"}
|
||||
Scramble | layer.applyTextPreset {"preset":"scramble"}
|
||||
Blur In | layer.applyTextPreset {"preset":"blurIn"}
|
||||
Jitter | layer.applyTextPreset {"preset":"jitter"}
|
||||
Drop In Lines | layer.applyTextPreset {"preset":"dropInLines"}
|
||||
---
|
||||
Add Keyframe | anim.addKeyframe
|
||||
Toggle Hold Keyframe | keys.toggleHold
|
||||
Keyframe Interpolation... | keys.interpolation
|
||||
Keyframe Velocity... | keys.velocity
|
||||
Keyframe Assistant
|
||||
Convert Audio to Keyframes | keys.audioToKeyframes
|
||||
Convert Expression to Keyframes | prop.convertExpressionToKeyframes
|
||||
Create Keyframes from Data | layer.create {"op":"keyframesFromData"}
|
||||
Easy Ease | keys.easyEase
|
||||
Easy Ease In | keys.easyEaseIn
|
||||
Easy Ease Out | keys.easyEaseOut
|
||||
Exponential Scale | keys.exponentialScale
|
||||
Null Controllers for Path Points | layer.create {"op":"nullsPathPoints"}
|
||||
Null Controllers for Positional Points | layer.create {"op":"nullsPositional"}
|
||||
Null Tracing Along Path | layer.create {"op":"nullTracing"}
|
||||
Nulls Following Path Points | layer.create {"op":"nullsFollowing"}
|
||||
RPF Camera Import | keys.rpfCameraImport
|
||||
Sequence Layers... | layer.sequence
|
||||
Time-Reverse Keyframes | keys.timeReverse
|
||||
---
|
||||
Animate Text
|
||||
Enable Per-character 3D | layer.enablePerChar3D {"toggle":true}
|
||||
---
|
||||
Anchor Point | layer.addTextAnimator {"properties":["anchor"]}
|
||||
Position | layer.addTextAnimator {"properties":["position"]}
|
||||
Scale | layer.addTextAnimator {"properties":["scale"]}
|
||||
Skew | layer.addTextAnimator {"properties":["skew"]}
|
||||
Rotation | layer.addTextAnimator {"properties":["rotation"]}
|
||||
Opacity | layer.addTextAnimator {"properties":["opacity"]}
|
||||
---
|
||||
All Transform Properties | layer.addTextAnimator {"properties":["transformAll"]}
|
||||
---
|
||||
Fill Color
|
||||
RGB | layer.addTextAnimator {"properties":["fillColor"]}
|
||||
Hue | layer.addTextAnimator {"properties":["fillHue"]}
|
||||
Saturation | layer.addTextAnimator {"properties":["fillSaturation"]}
|
||||
Brightness | layer.addTextAnimator {"properties":["fillBrightness"]}
|
||||
Opacity | layer.addTextAnimator {"properties":["fillOpacity"]}
|
||||
Stroke Color
|
||||
RGB | layer.addTextAnimator {"properties":["strokeColor"]}
|
||||
Hue | layer.addTextAnimator {"properties":["strokeHue"]}
|
||||
Saturation | layer.addTextAnimator {"properties":["strokeSaturation"]}
|
||||
Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]}
|
||||
Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]}
|
||||
Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]}
|
||||
---
|
||||
Tracking | layer.addTextAnimator {"properties":["tracking"]}
|
||||
Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]}
|
||||
Line Spacing | layer.addTextAnimator {"properties":["lineSpacing"]}
|
||||
---
|
||||
Character Offset | layer.addTextAnimator {"properties":["characterOffset"]}
|
||||
Character Value | layer.addTextAnimator {"properties":["characterValue"]}
|
||||
---
|
||||
Blur | layer.addTextAnimator {"properties":["blur"]}
|
||||
Variable Font Axes | text.animatorFontAxes
|
||||
Add Text Selector
|
||||
Range | text.addSelector {"kind":"range"}
|
||||
Wiggly | text.addSelector {"kind":"wiggly"}
|
||||
Expression | text.addSelector {"kind":"expression"}
|
||||
Remove All Text Animators | text.removeAllAnimators
|
||||
---
|
||||
Add Expression | prop.setExpression
|
||||
Separate Dimensions | prop.separateDimensions
|
||||
Track Camera | track.camera
|
||||
Warp Stabilizer VFX | track.warpStabilizer
|
||||
Track Motion | track.motion
|
||||
Track Mask | track.mask
|
||||
Track this Property | track.property
|
||||
---
|
||||
Reveal Properties with Keyframes | anim.reveal {"kind":"keyframes"} | U
|
||||
Reveal Properties with Animation | anim.reveal {"kind":"animation"}
|
||||
Reveal All Modified Properties | anim.reveal {"kind":"modified"}
|
||||
View
|
||||
Split with New Locked Viewer | view.splitLockedViewer
|
||||
---
|
||||
Zoom In | view.zoomIn
|
||||
Zoom Out | view.zoomOut
|
||||
---
|
||||
Resolution
|
||||
Full | view.res.full
|
||||
Half | view.res.half
|
||||
Third | view.res.third
|
||||
Quarter | view.res.quarter
|
||||
Custom... | view.res.custom
|
||||
---
|
||||
Use Display Color Management | view.displayColorManagement
|
||||
Simulate Output
|
||||
No Output Simulation | view.simulateOutput {"profile":"none"}
|
||||
HDTV (Rec. 709) | view.simulateOutput {"profile":"rec709"}
|
||||
SDTV NTSC | view.simulateOutput {"profile":"ntsc"}
|
||||
SDTV PAL | view.simulateOutput {"profile":"pal"}
|
||||
Legacy Macintosh RGB (Gamma 1.8) | view.simulateOutput {"profile":"mac18"}
|
||||
Internet Standard RGB (sRGB) | view.simulateOutput {"profile":"srgb"}
|
||||
Custom... | view.simulateOutput {"profile":"custom"}
|
||||
---
|
||||
Show Rulers | view.rulers
|
||||
Panel Background Color
|
||||
Black | view.panelBackground {"color":"black"}
|
||||
Dark Gray | view.panelBackground {"color":"darkGray"}
|
||||
Medium Gray (Default) | view.panelBackground {"color":"mediumGray"}
|
||||
Light Gray | view.panelBackground {"color":"lightGray"}
|
||||
White | view.panelBackground {"color":"white"}
|
||||
---
|
||||
Custom | view.panelBackground {"color":"custom"}
|
||||
Select Custom Background Color... | view.panelBackground {"pick":true}
|
||||
---
|
||||
Show Guides | view.guides
|
||||
Snap to Guides | view.snapToGuides
|
||||
Lock Guides | view.lockGuides
|
||||
Add Guide... | view.addGuide
|
||||
Clear Guides | view.clearGuides
|
||||
Import Guides... | view.importGuides
|
||||
Export Guides... | view.exportGuides
|
||||
---
|
||||
Show Grid | view.grid
|
||||
Snap to Grid | view.snapToGrid
|
||||
---
|
||||
View Options... | view.options
|
||||
Show Layer Controls | view.layerControls
|
||||
---
|
||||
Camera Settings... | layer.cameraSettings
|
||||
Reset 3D View | view.reset3DView
|
||||
Create Camera from 3D View | camera.fromView
|
||||
Switch View Layout
|
||||
1 View | view.layout {"views":1}
|
||||
2 Views | view.layout {"views":2}
|
||||
4 Views | view.layout {"views":4}
|
||||
---
|
||||
Share View Options | view.shareViewOptions
|
||||
Switch 3D View
|
||||
Active Camera | view.3d.activeCamera
|
||||
Default | view.3d.default
|
||||
---
|
||||
Front | view.3d.front
|
||||
Left | view.3d.left
|
||||
Top | view.3d.top
|
||||
Back | view.3d.back
|
||||
Right | view.3d.right
|
||||
Bottom | view.3d.bottom
|
||||
---
|
||||
Custom View 1 | view.3d.custom1
|
||||
Custom View 2 | view.3d.custom2
|
||||
Custom View 3 | view.3d.custom3
|
||||
---
|
||||
Reset 3D View | view.reset3DView
|
||||
Switch to Last 3D View | view.3d.last | Escape
|
||||
Look at Selected Layers | view.lookAtSelected
|
||||
Look at All Layers | view.lookAtAll
|
||||
---
|
||||
Go to Time... | time.set
|
||||
Enter Full Screen | view.fullScreen
|
||||
Window
|
||||
Workspace
|
||||
All Panels | window.workspace {"name":"All Panels"}
|
||||
Animation | window.workspace {"name":"Animation"}
|
||||
Default | window.workspace {"name":"Default"} | Shift+F10
|
||||
Effects | window.workspace {"name":"Effects"}
|
||||
Minimal | window.workspace {"name":"Minimal"}
|
||||
Motion Tracking | window.workspace {"name":"Motion Tracking"}
|
||||
Paint | window.workspace {"name":"Paint"}
|
||||
Small Screen | window.workspace {"name":"Small Screen"} | Shift+F12
|
||||
Standard | window.workspace {"name":"Standard"} | Shift+F11
|
||||
Text | window.workspace {"name":"Text"}
|
||||
---
|
||||
Reset to Saved Layout | window.resetWorkspace
|
||||
Save Changes to this Workspace | window.saveWorkspace
|
||||
Save as New Workspace... | window.saveWorkspaceAs
|
||||
Edit Workspaces... | window.editWorkspaces
|
||||
---
|
||||
Align | window.panel {"panel":"align"}
|
||||
Audio | window.panel {"panel":"audio"} | Cmd+4
|
||||
Brushes | window.panel {"panel":"brushes"} | Cmd+9
|
||||
Character | window.panel {"panel":"character"} | Cmd+6
|
||||
Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"}
|
||||
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
|
||||
Info | window.panel {"panel":"info"} | Cmd+2
|
||||
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
|
||||
Mask Interpolation | window.unavailablePanel {"panel":"maskInterpolation"}
|
||||
Media Browser | window.unavailablePanel {"panel":"mediaBrowser"}
|
||||
Metadata | window.unavailablePanel {"panel":"metadata"}
|
||||
Motion Sketch | window.panel {"panel":"motionSketch"}
|
||||
Paint | window.panel {"panel":"paint"} | Cmd+8
|
||||
Paragraph | window.panel {"panel":"paragraph"} | Cmd+7
|
||||
Preview | window.panel {"panel":"preview"} | Cmd+3
|
||||
Progress | window.unavailablePanel {"panel":"progress"}
|
||||
Properties | window.panel {"panel":"properties"}
|
||||
Smoother | window.panel {"panel":"smoother"}
|
||||
Tools | window.panel {"panel":"tools"} | Cmd+1
|
||||
Tracker | window.panel {"panel":"tracker"}
|
||||
Wiggler | window.panel {"panel":"wiggler"}
|
||||
---
|
||||
Composition | window.panel {"panel":"composition"}
|
||||
Effect Controls | window.panel {"panel":"effectControls"} | F3
|
||||
Flowchart | window.panel {"panel":"flowchart"}
|
||||
Footage | window.unavailablePanel {"panel":"footage"}
|
||||
Layer | window.panel {"panel":"layer"}
|
||||
Project | window.panel {"panel":"project"} | Cmd+0
|
||||
Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0
|
||||
Timeline | window.panel {"panel":"timeline"}
|
||||
Help
|
||||
EffectCraft Help... | help.docs {"page":"help"} | F1
|
||||
Scripting Help... | help.docs {"page":"scripting"}
|
||||
Expression Reference... | help.docs {"page":"expressions"}
|
||||
Effect Reference... | help.docs {"page":"effects"}
|
||||
Animation Presets... | anim.browsePresets
|
||||
Keyboard Shortcuts... | app.keyboardShortcuts
|
||||
---
|
||||
Join the ArtCraft Discord... | help.discord
|
||||
Provide Feedback... | help.reportIssue
|
||||
---
|
||||
ArtCraft Website | help.website
|
||||
EffectCraft Home Page | help.appPage
|
||||
EffectCraft on GitHub | help.github
|
||||
Open Demo Project | file.openDemoProject
|
||||
[!mac] ---
|
||||
[!mac] About EffectCraft... | app.about
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn all_entries(nodes: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<String>, MenuEntry)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Item(e) => out.push((path.clone(), e.clone())),
|
||||
MenuNode::Separator => {}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
path.push(label.clone());
|
||||
all_entries(children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_entries(mac: bool) -> Vec<(Vec<String>, MenuEntry)> {
|
||||
let tree = parse(TREE, mac).unwrap();
|
||||
let mut out = vec![];
|
||||
all_entries(&tree, &mut vec![], &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_order_matches_after_effects() {
|
||||
let mac: Vec<String> =
|
||||
parse(TREE, true).unwrap().iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.clone()) } else { None }).collect();
|
||||
assert_eq!(mac, ["EffectCraft", "File", "Edit", "Composition", "Layer", "Effect", "Animation", "View", "Window", "Help"]);
|
||||
let other: Vec<String> =
|
||||
parse(TREE, false).unwrap().iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.clone()) } else { None }).collect();
|
||||
assert_eq!(other, ["File", "Edit", "Composition", "Layer", "Effect", "Animation", "View", "Window", "Help"]);
|
||||
// The live bar is one of the two.
|
||||
assert!(top_level().len() == mac.len() || top_level().len() == other.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_entry_resolves_to_a_registered_command() {
|
||||
for mac in [true, false] {
|
||||
for (path, e) in platform_entries(mac) {
|
||||
assert!(crate::commands::find(&e.command).is_some(), "{} ▸ {}: unknown command `{}`", path.join(" ▸ "), e.label, e.command);
|
||||
assert!(e.params.is_null() || e.params.is_object(), "{}: params must be an object", e.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_menu_paths_and_labels_agree_with_the_tree() {
|
||||
let entries = platform_entries(true);
|
||||
let mut by_cmd: BTreeMap<&str, Vec<&(Vec<String>, MenuEntry)>> = BTreeMap::new();
|
||||
for e in &entries {
|
||||
by_cmd.entry(e.1.command.as_str()).or_default().push(e);
|
||||
}
|
||||
for spec in crate::command_specs() {
|
||||
if spec.menu.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let places = by_cmd.get(spec.id).unwrap_or_else(|| panic!("`{}` declares menu {:?} but is not in the menu tree", spec.id, spec.menu));
|
||||
assert!(
|
||||
places.iter().any(|(p, _)| p.iter().map(String::as_str).eq(spec.menu.iter().copied())),
|
||||
"`{}` declares menu {:?}; the tree has it at {:?}",
|
||||
spec.id,
|
||||
spec.menu,
|
||||
places.iter().map(|(p, _)| p.join(" ▸ ")).collect::<Vec<_>>()
|
||||
);
|
||||
for (p, e) in places {
|
||||
if e.params.is_null() && p.iter().map(String::as_str).eq(spec.menu.iter().copied()) {
|
||||
assert_eq!(e.label, spec.label, "label of `{}`", spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_labels_use_ascii_ellipsis() {
|
||||
for (_, e) in platform_entries(true) {
|
||||
assert!(!e.label.contains('…'), "`{}` uses a Unicode ellipsis; AE's menus use \"...\"", e.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_are_unambiguous() {
|
||||
// shortcut → (command, params) from command defaults and menu entries.
|
||||
let mut seen: BTreeMap<String, (String, String)> = BTreeMap::new();
|
||||
let mut add = |sc: &str, id: &str, params: String| {
|
||||
if sc == "Num*" {
|
||||
return;
|
||||
}
|
||||
if let Some(prev) = seen.get(sc) {
|
||||
assert_eq!(prev, &(id.to_string(), params.clone()), "shortcut {sc} is bound twice");
|
||||
} else {
|
||||
seen.insert(sc.to_string(), (id.to_string(), params));
|
||||
}
|
||||
};
|
||||
for (_, e) in platform_entries(true) {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
add(sc, &e.command, e.params.to_string());
|
||||
}
|
||||
}
|
||||
for c in crate::command_specs() {
|
||||
// Defaults of commands whose menu entries bind params are carried by the entries.
|
||||
if let Some(sc) = c.shortcut
|
||||
&& !platform_entries(true).iter().any(|(_, e)| e.command == c.id && !e.params.is_null())
|
||||
{
|
||||
add(sc, c.id, "null".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_cover_after_effects_structure() {
|
||||
let entries = platform_entries(true);
|
||||
let count = |m: &str| entries.iter().filter(|(p, _)| p[0] == m).count();
|
||||
// Floors (AE 2026 minus Adobe-service, history and user-specific entries).
|
||||
assert!(count("File") >= 40, "File {}", count("File"));
|
||||
assert!(count("Edit") >= 54, "Edit {}", count("Edit"));
|
||||
assert!(count("Composition") >= 19, "Composition {}", count("Composition"));
|
||||
assert!(count("Layer") >= 197, "Layer {}", count("Layer"));
|
||||
assert!(count("Animation") >= 53, "Animation {}", count("Animation"));
|
||||
assert!(count("View") >= 59, "View {}", count("View"));
|
||||
assert!(count("Window") >= 43, "Window {}", count("Window"));
|
||||
// 38 blending modes + next/previous.
|
||||
let modes = entries.iter().filter(|(p, e)| p.last().map(String::as_str) == Some("Blending Mode") && e.command == "layer.setBlendMode").count();
|
||||
assert_eq!(modes, 38);
|
||||
// 16 label colours + None.
|
||||
let labels = entries.iter().filter(|(_, e)| e.command == "edit.label").count();
|
||||
assert_eq!(labels, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_reflects_layer_state() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("comp.new", serde_json::json!({"width": 320, "height": 180})).unwrap();
|
||||
s.execute("layer.newSolid", serde_json::json!({})).unwrap();
|
||||
s.execute("layer.setBlendMode", serde_json::json!({"mode": "Screen"})).unwrap();
|
||||
assert_eq!(checked(&s, "layer.setBlendMode", &serde_json::json!({"mode": "Screen"})), Some(true));
|
||||
assert_eq!(checked(&s, "layer.setBlendMode", &serde_json::json!({"mode": "Normal"})), Some(false));
|
||||
assert_eq!(checked(&s, "layer.quality", &serde_json::json!({"quality": "best"})), Some(true));
|
||||
assert_eq!(checked(&s, "file.save", &serde_json::json!({})), None);
|
||||
}
|
||||
|
||||
/// `cargo test -p effectcraft-engine dump_specs -- --ignored --nocapture`: every command as TSV.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dump_specs() {
|
||||
for c in crate::command_specs() {
|
||||
eprintln!("SPEC\t{}\t{}\t{}\t{}\t{}", c.id, c.label, c.menu.join(" > "), c.shortcut.unwrap_or(""), c.params);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_trees() {
|
||||
assert!(parse("File\n Empty\n", true).is_err());
|
||||
assert!(parse("File\n Odd | file.save\n", true).is_err());
|
||||
assert!(parse("File\n Bad | file.save {nope}\n", true).is_err());
|
||||
}
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
//! Tests for the menu-parity command families (Layer / Edit / Animation / File / Composition /
|
||||
//! View menus), including undo.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Event, Session};
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, color: &str) -> u64 {
|
||||
s.execute("layer.newSolid", json!({"color": color, "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-menu-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn frontend_events(s: &mut Session) -> Vec<(String, Value)> {
|
||||
s.drain_events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::Frontend { command, params } => Some((command, params)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_quality_sampling_frame_blending_and_undo() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("layer.quality", json!({"quality": "wireframe"})).unwrap();
|
||||
s.execute("layer.sampling", json!({"sampling": "bicubic"})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": "pixelMotion"})).unwrap();
|
||||
let l = layer(&s, a);
|
||||
assert_eq!((l.switches.quality, l.switches.sampling, l.switches.frame_blend), (Quality::Wireframe, Sampling::Bicubic, FrameBlend::PixelMotion));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).switches.frame_blend, FrameBlend::Off);
|
||||
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("layer.hideOtherVideo", json!({})).unwrap();
|
||||
assert!(!layer(&s, a).switches.video && layer(&s, b).switches.video);
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
assert!(layer(&s, a).switches.video);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "lock"})).unwrap();
|
||||
assert!(layer(&s, a).switches.locked);
|
||||
s.execute("layer.unlockAll", json!({})).unwrap();
|
||||
assert!(!layer(&s, a).switches.locked && !layer(&s, b).switches.locked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, a).switches.locked);
|
||||
// Expressions on / off.
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
s.execute("layer.expressions", json!({"layers": [a], "enabled": false})).unwrap();
|
||||
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().enabled);
|
||||
s.execute("layer.expressions", json!({"layers": [a], "enabled": true})).unwrap();
|
||||
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().has_expression());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_dialog_values_and_center_anchor() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("layer.setTransform", json!({"prop": "position", "value": [100, 50]})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"prop": "rotation", "value": 90})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"prop": "opacity", "value": 25})).unwrap();
|
||||
let l = layer(&s, a);
|
||||
assert_eq!(l.props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 50.0, 0.0]));
|
||||
assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 25.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
|
||||
assert!(s.execute("layer.setTransform", json!({"prop": "skew", "value": 1})).is_err());
|
||||
|
||||
// Move the anchor off-centre, then Center Anchor Point keeps the layer in place.
|
||||
s.execute("layer.setTransform", json!({"prop": "anchor", "value": [0, 0]})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"prop": "rotation", "value": 0})).unwrap();
|
||||
let before = layer(&s, a).props.prop("transform/position").unwrap().value.as_vec3();
|
||||
s.execute("layer.centerAnchor", json!({})).unwrap();
|
||||
let l = layer(&s, a);
|
||||
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), [100.0, 50.0]);
|
||||
let after = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||
assert!((after[0] - before[0] - 100.0).abs() < 1e-6 && (after[1] - before[1] - 50.0).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
|
||||
s.execute("layer.autoOrient", json!({"mode": "alongPath"})).unwrap();
|
||||
assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_menu_on_existing_masks() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
assert!(!s.is_enabled("layer.mask.reset"));
|
||||
s.execute("layer.addMask", json!({})).unwrap();
|
||||
s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap();
|
||||
assert!(s.is_enabled("layer.mask.reset"));
|
||||
let masks = |s: &Session| layer(s, a).masks().unwrap().groups().cloned().collect::<Vec<_>>();
|
||||
s.execute("layer.mask.set", json!({"field": "feather", "value": 12, "mask": 1})).unwrap();
|
||||
assert_eq!(masks(&s)[0].get("feather").unwrap().value, KV::Vec2([12.0, 12.0]));
|
||||
s.execute("layer.mask.set", json!({"field": "opacity", "value": 40})).unwrap();
|
||||
assert!(masks(&s).iter().all(|m| m.get("opacity").unwrap().value.as_f64() == 40.0));
|
||||
s.execute("layer.mask.invert", json!({"mask": 2})).unwrap();
|
||||
s.execute("layer.mask.mode", json!({"mode": "Subtract", "mask": 2})).unwrap();
|
||||
s.execute("layer.mask.lock", json!({"mask": 1})).unwrap();
|
||||
let kinds: Vec<_> = masks(&s).iter().map(|m| m.kind.clone()).collect();
|
||||
assert!(matches!(kinds[1], effectcraft_project::GroupKind::Mask { inverted: true, mode: effectcraft_project::MaskMode::Subtract, .. }));
|
||||
assert!(matches!(kinds[0], effectcraft_project::GroupKind::Mask { locked: true, .. }));
|
||||
s.execute("layer.mask.unlockAll", json!({})).unwrap();
|
||||
assert!(matches!(masks(&s)[0].kind, effectcraft_project::GroupKind::Mask { locked: false, .. }));
|
||||
s.execute("layer.mask.lockOthers", json!({"mask": 1})).unwrap();
|
||||
assert!(matches!(masks(&s)[1].kind, effectcraft_project::GroupKind::Mask { locked: true, .. }));
|
||||
s.execute("layer.mask.reset", json!({"mask": 1})).unwrap();
|
||||
assert_eq!(masks(&s)[0].get("opacity").unwrap().value.as_f64(), 100.0);
|
||||
s.execute("layer.mask.shape", json!({"mask": 1, "rect": [10, 10, 50, 40], "shape": "ellipse"})).unwrap();
|
||||
s.execute("layer.mask.remove", json!({"mask": 2})).unwrap();
|
||||
assert_eq!(masks(&s).len(), 1);
|
||||
s.execute("layer.mask.removeAll", json!({})).unwrap();
|
||||
assert!(masks(&s).is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(masks(&s).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_markers_lock_and_delete() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("layer.addMarker", json!({"comment": "hit"})).unwrap();
|
||||
s.execute("layer.addMarker", json!({"time": 2.0})).unwrap();
|
||||
assert_eq!(layer(&s, a).markers.len(), 2);
|
||||
s.execute("layer.markersLock", json!({})).unwrap();
|
||||
assert!(layer(&s, a).markers_locked);
|
||||
s.execute("layer.deleteAllMarkers", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).markers.len(), 2, "locked markers survive");
|
||||
s.execute("layer.markersLock", json!({"value": false})).unwrap();
|
||||
s.execute("layer.deleteAllMarkers", json!({})).unwrap();
|
||||
assert!(layer(&s, a).markers.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).markers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_matte_menu() {
|
||||
let mut s = comp();
|
||||
let below = solid(&mut s, "#ff0000");
|
||||
let top = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.select", json!({"layers": [below]})).unwrap();
|
||||
s.execute("layer.trackMatte", json!({"op": "luma"})).unwrap();
|
||||
let m = layer(&s, below).track_matte.unwrap();
|
||||
assert_eq!((m.layer.0, m.kind), (top, MatteKind::Luma));
|
||||
assert!(!layer(&s, top).switches.video);
|
||||
s.execute("layer.trackMatte", json!({"op": "alphaInverted"})).unwrap();
|
||||
assert_eq!(layer(&s, below).track_matte.unwrap().kind, MatteKind::AlphaInverted);
|
||||
s.execute("layer.trackMatte", json!({"op": "none"})).unwrap();
|
||||
assert!(layer(&s, below).track_matte.is_none());
|
||||
s.execute("layer.select", json!({"layers": [top]})).unwrap();
|
||||
assert!(s.execute("layer.trackMatte", json!({"op": "above"})).is_err());
|
||||
s.execute("layer.trackMatte", json!({"op": "below"})).unwrap();
|
||||
assert_eq!(layer(&s, top).track_matte.unwrap().layer.0, below);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_clipboard_paste_and_paste_reversed() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.key_clipboard.iter().map(|c| c.keys.len()).sum::<usize>(), 2);
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone();
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[0].value.as_f64() == 100.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
|
||||
let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone();
|
||||
assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_links_and_expression_only() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copyWithPropertyLinks", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let e = layer(&s, b).props.prop("transform/position").unwrap().expr.clone().unwrap();
|
||||
let name = layer(&s, a).name;
|
||||
assert_eq!(e.text, format!("comp(\"Main\").layer({}).transform(\"Position\")", serde_json::to_string(&name).unwrap()));
|
||||
// Relative links use thisComp.
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copyWithRelativePropertyLinks", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(layer(&s, b).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text.starts_with("thisComp.layer("));
|
||||
// Copy Expression Only.
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/rotation"})).unwrap();
|
||||
s.execute("edit.copyExpressionOnly", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90");
|
||||
// Layers with property links: the pasted copy follows the original.
|
||||
s.state.selected_props.clear();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("edit.copyWithPropertyLinks", json!({})).unwrap();
|
||||
let new = s.execute("edit.paste", json!({})).unwrap();
|
||||
let nid = new[0].as_u64().unwrap();
|
||||
assert!(layer(&s, nid).props.prop("transform/scale").unwrap().has_expression());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lift_and_extract_work_area() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap();
|
||||
s.execute("edit.liftWorkArea", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers.len(), 2, "split around the gap");
|
||||
let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
|
||||
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
assert!((spans[0].1 - 2.0).abs() < 1e-6 && (spans[1].0 - 4.0).abs() < 1e-6);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("edit.extractWorkArea", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect();
|
||||
spans.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_group_purge_and_edit_original() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let _c = solid(&mut s, "#0000ff");
|
||||
s.execute("edit.label", json!({"layers": [a, b], "label": "Cyan"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.len(), 2);
|
||||
s.drain_events();
|
||||
s.execute("edit.purge", json!({"what": "memory"})).unwrap();
|
||||
assert!(s.drain_events().contains(&Event::PurgeCaches));
|
||||
assert!(!s.is_enabled("edit.editOriginal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_menu_keys_presets_text() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
// Add Keyframe on selected properties.
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
|
||||
s.execute("anim.addKeyframe", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [400, 400]})).unwrap();
|
||||
assert_eq!(layer(&s, a).props.prop("transform/scale").unwrap().keys.len(), 2);
|
||||
// Exponential Scale: a key per frame, geometric in between.
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
|
||||
s.execute("keys.exponentialScale", json!({})).unwrap();
|
||||
let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone();
|
||||
assert_eq!(keys.len(), 31);
|
||||
assert!((keys[15].value.as_vec3()[0] - 200.0).abs() < 1e-6, "{:?}", keys[15].value);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Time-Reverse Keyframes.
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
|
||||
s.execute("keys.timeReverse", json!({})).unwrap();
|
||||
let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone();
|
||||
assert_eq!((keys[0].value.as_vec3()[0], keys[1].value.as_vec3()[0]), (400.0, 100.0));
|
||||
|
||||
// Presets round-trip through a file onto another layer at the CTI.
|
||||
let path = tmp("scale.ecpreset");
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap();
|
||||
s.execute("anim.savePreset", json!({"path": path})).unwrap();
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("time.set", json!({"time": 3.0})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [b]})).unwrap();
|
||||
assert_eq!(r["applied"], 1);
|
||||
let keys = layer(&s, b).props.prop("transform/scale").unwrap().keys.clone();
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert!((keys[0].time.seconds() - 3.0).abs() < 1e-6);
|
||||
|
||||
// Text: animate, add selector, reveal, remove all.
|
||||
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["anchor", "position", "scale", "skew", "rotation", "opacity"]})).unwrap();
|
||||
s.execute("text.addSelector", json!({"kind": "range"})).unwrap();
|
||||
let anim = layer(&s, t).props.group("text/animators").unwrap().groups().next().unwrap().clone();
|
||||
assert_eq!(anim.sub("selectors").unwrap().children.len(), 2);
|
||||
// Skew brings Skew Axis along.
|
||||
assert_eq!(anim.sub("properties").unwrap().children.len(), 7);
|
||||
s.execute("text.addSelector", json!({"kind": "wiggly"})).unwrap();
|
||||
assert!(s.execute("text.addSelector", json!({"kind": "other"})).is_err());
|
||||
s.execute("text.removeAllAnimators", json!({})).unwrap();
|
||||
assert!(layer(&s, t).props.group("text/animators").unwrap().children.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, t).props.group("text/animators").unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_properties() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/rotation"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [50, 50]})).unwrap();
|
||||
s.drain_events();
|
||||
let n = |v: &Value| v["props"].as_array().unwrap().len();
|
||||
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "keyframes"})).unwrap()), 1);
|
||||
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "animation"})).unwrap()), 2);
|
||||
assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "modified"})).unwrap()), 3);
|
||||
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
for (id, out) in [(a, 2.0), (b, 3.0), (c, 1.0)] {
|
||||
s.execute("layer.timing", json!({"layers": [id], "out": out})).unwrap();
|
||||
}
|
||||
s.execute("layer.sequence", json!({"layers": [a, b, c], "overlap": true, "duration": 0.5, "transition": "dissolveFront"})).unwrap();
|
||||
assert!((layer(&s, b).in_point.seconds() - 1.5).abs() < 1e-6);
|
||||
assert!((layer(&s, c).in_point.seconds() - 4.0).abs() < 1e-6);
|
||||
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_project_ops() {
|
||||
let mut s = comp();
|
||||
let f = s.execute("project.newFolder", json!({"name": "Stuff"})).unwrap()["item"].as_u64().unwrap();
|
||||
assert!(s.project.item(effectcraft_project::ItemId(f)).unwrap().is_folder());
|
||||
let p1 = s.execute("file.importPlaceholder", json!({"name": "Shot 1", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
|
||||
let p2 = s.execute("file.importPlaceholder", json!({"name": "Unused"})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("file.importSolid", json!({"name": "Grey"})).unwrap();
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
|
||||
let r = s.execute("file.newCompFromSelection", json!({})).unwrap();
|
||||
let nc = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!((s.project.comp(nc).unwrap().width, s.project.comp(nc).unwrap().layers.len()), (320, 1));
|
||||
// Missing footage = the placeholders.
|
||||
let miss = s.execute("file.findMissing", json!({"what": "footage"})).unwrap();
|
||||
assert_eq!(miss["items"].as_array().unwrap().len(), 2);
|
||||
assert!(s.execute("file.findMissing", json!({"what": "fonts"})).is_ok());
|
||||
// Interpretation.
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
|
||||
s.execute("file.interpretFootage", json!({"frameRate": 24, "alpha": "premultiplied", "loop": 3})).unwrap();
|
||||
s.execute("file.rememberInterpretation", json!({})).unwrap();
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p2)];
|
||||
s.execute("file.applyInterpretation", json!({})).unwrap();
|
||||
match &s.project.item(effectcraft_project::ItemId(p2)).unwrap().kind {
|
||||
ItemKind::Footage(f) => assert_eq!((f.alpha, f.loop_count, f.frame_rate.as_f64().round()), (effectcraft_project::AlphaMode::Premultiplied, 3, 24.0)),
|
||||
_ => panic!(),
|
||||
}
|
||||
// Remove Unused Footage drops the unused placeholder and the unused solid.
|
||||
let before = s.project.items.len();
|
||||
s.execute("file.removeUnusedFootage", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before - 2);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
// Replace with a solid retargets the layer.
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p1)];
|
||||
s.execute("file.replaceWithSolid", json!({"color": "#336699"})).unwrap();
|
||||
assert!(matches!(s.project.comp(nc).unwrap().layers[0].source, effectcraft_project::LayerSource::Solid { .. }));
|
||||
// Reduce Project keeps only the selected comp and what it uses (+ folders of kept items).
|
||||
s.state.project_selection = vec![nc];
|
||||
s.execute("file.reduceProject", json!({})).unwrap();
|
||||
assert!(s.project.items.len() <= 2, "{:?}", s.project.items.values().map(|i| &i.name).collect::<Vec<_>>());
|
||||
// Save a copy leaves the session path alone.
|
||||
let path = tmp("copy.ecproj");
|
||||
s.execute("file.saveCopy", json!({"path": path})).unwrap();
|
||||
assert!(s.path.is_none() && std::fs::metadata(&path).is_ok());
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
assert!(s.project.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_duplicate_footage() {
|
||||
let mut s = comp();
|
||||
let f = |s: &mut Session| s.execute("file.importPlaceholder", json!({})).unwrap()["item"].as_u64().unwrap();
|
||||
let a = f(&mut s);
|
||||
let b = f(&mut s);
|
||||
// Give both the same file path.
|
||||
let mut p = (*s.project).clone();
|
||||
for id in [a, b] {
|
||||
if let Some(ItemKind::Footage(ft)) = p.item_mut(effectcraft_project::ItemId(id)).map(|i| &mut i.kind) {
|
||||
ft.path = "/media/shot.mov".into();
|
||||
}
|
||||
}
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.execute("layer.addItem", json!({"item": b})).unwrap();
|
||||
assert_eq!(s.execute("file.consolidateFootage", json!({})).unwrap()["removed"], 1);
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers[0].source.item().unwrap().0, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_script_steps() {
|
||||
let mut s = comp();
|
||||
let path = tmp("script.jsonl");
|
||||
std::fs::write(&path, "// make two solids\n{\"command\":\"layer.newSolid\",\"params\":{\"color\":\"#ff0000\"}}\n{\"method\":\"engine.execute\",\"params\":{\"id\":\"layer.newNull\",\"params\":{}}}\n").unwrap();
|
||||
let r = s.execute("file.runScript", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["steps"], 2);
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
||||
assert!(s.execute("file.runScript", json!({"steps": [{"command": "nope.nope"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_comp_and_save_frame() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
// ROI crop moves layers so nothing shifts on screen.
|
||||
assert!(!s.is_enabled("comp.cropToRegionOfInterest"));
|
||||
s.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 100]})).unwrap();
|
||||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!((c.width, c.height), (200, 100));
|
||||
assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [220.0, 130.0]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().width, 640);
|
||||
// Crop to the selected layer's bounds (a 200×100 solid centred in 640×360).
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let r = s.execute("comp.cropToLayerBounds", json!({})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)));
|
||||
assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [100.0, 50.0]);
|
||||
// Save Frame As ▸ File… writes a PNG.
|
||||
let path = tmp("frame.png");
|
||||
let r = s.execute("comp.saveFrameAs", json!({"path": path, "scale": 0.5})).unwrap();
|
||||
assert_eq!(r["width"], 100);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
|
||||
// Responsive Design — Time.
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap();
|
||||
let m = &s.active_comp().unwrap().markers[0];
|
||||
assert!(m.protected && (m.duration.seconds() - 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_add_clear_import_export() {
|
||||
let mut s = comp();
|
||||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
|
||||
s.execute("view.addGuide", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
|
||||
assert_eq!(s.active_comp().unwrap().guides[1].position, 320.0);
|
||||
let path = tmp("guides.json");
|
||||
s.execute("view.exportGuides", json!({"path": path})).unwrap();
|
||||
s.execute("view.clearGuides", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().guides.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
|
||||
s.execute("view.clearGuides", json!({})).unwrap();
|
||||
s.execute("view.importGuides", json!({"path": path})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().guides.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_commands_emit_events_and_stubs_are_disabled() {
|
||||
let mut s = comp();
|
||||
s.drain_events();
|
||||
s.execute("view.zoomIn", json!({})).unwrap();
|
||||
s.execute("window.panel", json!({"panel": "align"})).unwrap();
|
||||
let ev = frontend_events(&mut s);
|
||||
assert_eq!(ev[0].0, "view.zoomIn");
|
||||
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
|
||||
for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.create", "track.motion"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +168,21 @@ fn presets_apply_render_and_round_trip() {
|
|||
fn menus_have_text_animation_entries() {
|
||||
let (s, _) = session();
|
||||
let spec = crate::commands::find("layer.enablePerChar3D").unwrap();
|
||||
assert_eq!(spec.menu, ["Animation", "Animate Text"]);
|
||||
assert!(spec.menu.is_empty(), "placed by the engine menu tree");
|
||||
let entries = crate::menus::entries();
|
||||
let has = |path: &[&str], cmd: &str| {
|
||||
let (label, parent) = path.split_last().unwrap();
|
||||
entries.iter().any(|(p, e)| p.iter().map(String::as_str).eq(parent.iter().copied()) && e.label == *label && e.command == cmd)
|
||||
};
|
||||
assert!(has(&["Animation", "Animate Text", "Enable Per-character 3D"], "layer.enablePerChar3D"));
|
||||
assert!(has(&["Animation", "Animate Text", "Fill Color", "Hue"], "layer.addTextAnimator"));
|
||||
assert!(has(&["Animation", "Add Text Selector", "Wiggly"], "text.addSelector"));
|
||||
assert!(has(&["Animation", "Text Animation Presets", "Typewriter"], "layer.applyTextPreset"));
|
||||
// Every preset in the menu exists.
|
||||
let ids: Vec<String> = crate::text_presets().into_iter().map(|p| p.0).collect();
|
||||
for (_, e) in entries.iter().filter(|(_, e)| e.command == "layer.applyTextPreset") {
|
||||
assert!(ids.iter().any(|i| e.params["preset"] == i.as_str()), "{:?}", e.params);
|
||||
}
|
||||
assert!(s.is_enabled("layer.enablePerChar3D"));
|
||||
assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,7 +398,15 @@ impl Inner {
|
|||
if !c.inflight.contains(&loc.key) {
|
||||
break;
|
||||
}
|
||||
// another thread (or the read-ahead) is decoding this frame
|
||||
// Another thread (or the read-ahead) is decoding this frame. Never block a rayon
|
||||
// worker on that: while the decoder waits on its own parallel join, the worker can
|
||||
// steal this very job, and waiting here would then wait on a decode further down
|
||||
// its own stack (deadlock). Decode a private copy instead.
|
||||
if rayon::current_thread_index().is_some() {
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
drop(c);
|
||||
return Ok(Arc::new(self.decode(&loc, footage)?));
|
||||
}
|
||||
c = self.done.wait(c).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
|
|
@ -414,7 +422,8 @@ impl Inner {
|
|||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick) {
|
||||
let (me, f) = (self.clone(), footage.clone());
|
||||
self.prefetched.fetch_add(1, Ordering::Relaxed);
|
||||
rayon::spawn(move || {
|
||||
// A plain thread, not a rayon job: the decode below parallelises with rayon itself.
|
||||
std::thread::spawn(move || {
|
||||
let _ = me.frame_at(&f, t, false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -719,6 +719,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
|||
track_matte: None,
|
||||
parent: None,
|
||||
markers: vec![],
|
||||
markers_locked: false,
|
||||
auto_orient,
|
||||
props: root,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,16 @@ pub struct Comp {
|
|||
/// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options).
|
||||
#[serde(default)]
|
||||
pub global_light: styles::GlobalLight,
|
||||
/// Viewer guides (View ▸ Add Guide…), in comp pixels.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<Guide>,
|
||||
}
|
||||
|
||||
/// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
pub vertical: bool,
|
||||
pub position: f64,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
|
|
@ -182,6 +192,7 @@ impl Comp {
|
|||
enable_frame_blending: true,
|
||||
draft_3d: false,
|
||||
poster_time: Tick::ZERO,
|
||||
guides: vec![],
|
||||
global_light: styles::GlobalLight::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -416,6 +427,9 @@ pub struct Layer {
|
|||
pub parent: Option<LayerId>,
|
||||
#[serde(default)]
|
||||
pub markers: Vec<Marker>,
|
||||
/// Layer ▸ Markers ▸ Lock Markers.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub markers_locked: bool,
|
||||
#[serde(default)]
|
||||
pub auto_orient: AutoOrient,
|
||||
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
//! Audio mixdown of a composition: every audible footage layer (and, recursively, precomp layer)
|
||||
//! summed with its Audio Levels (dB, per channel), respecting in/out points, the Audio switch and
|
||||
//! solo. Levels are evaluated once per call (callers mix in short blocks, e.g. one video frame).
|
||||
//! Audio mixdown of a composition: every audible layer (footage with audio, precomps, and any
|
||||
//! layer with a sound-generating effect such as Tone) summed with its Audio Levels (dB, per
|
||||
//! channel), respecting in/out points, the Audio switch and solo. Layer audio effects
|
||||
//! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before
|
||||
//! its levels; Backwards plays the layer's [in, out) span reversed.
|
||||
//!
|
||||
//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels
|
||||
//! are evaluated once per block. To make the result independent of block boundaries, layer
|
||||
//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's
|
||||
//! tail is kept — so preview playback and export produce identical samples.
|
||||
//!
|
||||
//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are
|
||||
//! silent for now.
|
||||
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
|
||||
use effectcraft_effects::Params;
|
||||
use effectcraft_effects::audio_fx;
|
||||
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerSource, Node, Project};
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{EvalCtx, ExprHost, FootageSource};
|
||||
|
|
@ -19,32 +29,49 @@ pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dy
|
|||
out
|
||||
}
|
||||
|
||||
/// Whether the comp has anything audible (footage with audio, or a precomp that has).
|
||||
/// Whether the comp has anything audible (footage with audio, a sound generator, or a precomp
|
||||
/// that has).
|
||||
pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool {
|
||||
fn walk(project: &Project, comp: ItemId, depth: usize) -> bool {
|
||||
let Some(c) = project.comp(comp) else { return false };
|
||||
depth < MAX_DEPTH
|
||||
&& c.layers.iter().any(|l| {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => {
|
||||
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
|
||||
}
|
||||
LayerSource::Comp { item } => walk(project, *item, depth + 1),
|
||||
_ => false,
|
||||
}
|
||||
&& (generates(l)
|
||||
|| match &l.source {
|
||||
LayerSource::Footage { item } => {
|
||||
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
|
||||
}
|
||||
LayerSource::Comp { item } => walk(project, *item, depth + 1),
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
walk(project, comp, 0)
|
||||
}
|
||||
|
||||
fn audible(project: &Project, l: &Layer) -> bool {
|
||||
/// Enabled audio effects of a layer (in stack order) with their effect ids.
|
||||
fn audio_effect_groups(l: &Layer) -> impl Iterator<Item = (&str, &effectcraft_project::PropGroup)> {
|
||||
l.effects().filter(|_| l.switches.effects).into_iter().flat_map(|fx| fx.groups()).filter(|g| g.enabled).filter_map(|g| match &g.kind {
|
||||
GroupKind::Effect { effect } if audio_fx::is_audio_effect(effect) => Some((effect.as_str(), g)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer makes sound on its own (Tone).
|
||||
fn generates(l: &Layer) -> bool {
|
||||
audio_effect_groups(l).any(|(id, _)| audio_fx::generates_audio(id))
|
||||
}
|
||||
|
||||
/// Does the layer contribute to the mix (Audio switch on and something to hear)?
|
||||
pub fn audible(project: &Project, l: &Layer) -> bool {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
|
||||
LayerSource::Comp { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
&& (generates(l)
|
||||
|| match &l.source {
|
||||
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
|
||||
LayerSource::Comp { .. } => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn db_to_gain(db: f64) -> f32 {
|
||||
|
|
@ -61,6 +88,116 @@ fn sample_tick(i: i64, rate: u32) -> Tick {
|
|||
Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64)
|
||||
}
|
||||
|
||||
struct MixEnv<'a> {
|
||||
project: &'a Project,
|
||||
footage: &'a dyn FootageSource,
|
||||
expr: Option<&'a dyn ExprHost>,
|
||||
comp_id: ItemId,
|
||||
comp: &'a Comp,
|
||||
rate: u32,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl MixEnv<'_> {
|
||||
fn ctx(&self, t: Tick) -> EvalCtx<'_> {
|
||||
let mut ctx = EvalCtx::new(self.project, self.comp_id, self.comp, t);
|
||||
ctx.expr = self.expr;
|
||||
ctx
|
||||
}
|
||||
|
||||
/// The layer's own (pre-effect) audio for comp sample indices `i0 .. i0 + n`, silent
|
||||
/// outside its [in, out) span. Comp sample index `k` maps to `in + out − 1 − k` when
|
||||
/// `reversed`.
|
||||
fn layer_raw(&self, l: &Layer, i0: i64, n: usize, reversed: bool) -> Vec<f32> {
|
||||
let rate = self.rate;
|
||||
let mut out = vec![0.0f32; n * 2];
|
||||
let (in_i, out_i) = (units_ceil(l.in_point, rate), units_ceil(l.out_point, rate));
|
||||
if reversed {
|
||||
// Mirror the clipped range, fetch it forwards, reverse.
|
||||
let a = i0.max(in_i);
|
||||
let b = (i0 + n as i64).min(out_i);
|
||||
if a >= b {
|
||||
return out;
|
||||
}
|
||||
let (ma, mb) = (in_i + out_i - b, in_i + out_i - a);
|
||||
let fwd = self.layer_raw(l, ma, (mb - ma) as usize, false);
|
||||
for k in a..b {
|
||||
let j = (in_i + out_i - 1 - k - ma) as usize;
|
||||
let o = (k - i0) as usize;
|
||||
out[o * 2] = fwd[j * 2];
|
||||
out[o * 2 + 1] = fwd[j * 2 + 1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let a = (in_i - i0).clamp(0, n as i64) as usize;
|
||||
let b = (out_i - i0).clamp(0, n as i64) as usize;
|
||||
if a >= b {
|
||||
return out;
|
||||
}
|
||||
let m = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
let src_n = ((m as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(sample_tick(i0 + a as i64, rate));
|
||||
let buf = match &l.source {
|
||||
LayerSource::Footage { item } => match self.project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.has_audio => self.footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
|
||||
_ => return out,
|
||||
},
|
||||
LayerSource::Comp { item } => {
|
||||
if self.project.comp_contains(*item, self.comp_id) {
|
||||
return out;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => return out,
|
||||
};
|
||||
let same = (speed - 1.0).abs() < 1e-9;
|
||||
for i in 0..m {
|
||||
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
|
||||
if j * 2 + 1 >= buf.len() {
|
||||
break;
|
||||
}
|
||||
out[(a + i) * 2] = buf[j * 2];
|
||||
out[(a + i) * 2 + 1] = buf[j * 2 + 1];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Effect parameters at comp time `t`.
|
||||
fn params(&self, l: &Layer, g: &effectcraft_project::PropGroup, t: Tick) -> Params {
|
||||
let ctx = self.ctx(t);
|
||||
let mut p = Params::default();
|
||||
for c in &g.children {
|
||||
if let Node::Prop(pr) = c {
|
||||
p.values.insert(pr.match_id.clone(), ctx.value(l, pr));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The layer's processed audio (effects applied, levels not) for comp sample indices
|
||||
/// `i0 .. i0 + n`.
|
||||
fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec<f32> {
|
||||
let t = sample_tick(i0, self.rate);
|
||||
let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect();
|
||||
if fx.is_empty() {
|
||||
return self.layer_raw(l, i0, n, false);
|
||||
}
|
||||
let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id));
|
||||
let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum();
|
||||
let pre = (pre_s * self.rate as f64).ceil() as usize;
|
||||
let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed);
|
||||
let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds();
|
||||
for (id, p) in &fx {
|
||||
audio_fx::process(id, p, &mut buf, self.rate, start);
|
||||
}
|
||||
buf.drain(..pre * 2);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mix_into(
|
||||
project: &Project,
|
||||
|
|
@ -78,6 +215,7 @@ fn mix_into(
|
|||
if depth >= MAX_DEPTH || rate == 0 {
|
||||
return;
|
||||
}
|
||||
let env = MixEnv { project, footage, expr, comp_id, comp, rate, depth };
|
||||
let frames = out.len() / 2;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
|
|
@ -92,50 +230,64 @@ fn mix_into(
|
|||
continue;
|
||||
}
|
||||
let t_a = sample_tick(s0 + a as i64, rate);
|
||||
let (ll, lr) = levels(project, comp_id, comp, l, t_a, expr);
|
||||
let (ll, lr) = levels(&env, l, t_a);
|
||||
let (ll, lr) = (ll * gl, lr * gr);
|
||||
if ll == 0.0 && lr == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let n = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
let src_n = ((n as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(t_a);
|
||||
let buf = match &l.source {
|
||||
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
|
||||
_ => continue,
|
||||
},
|
||||
LayerSource::Comp { item } => {
|
||||
if project.comp_contains(*item, comp_id) {
|
||||
continue;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(project, footage, expr, *item, lt, rate, &mut sub, 1.0, 1.0, depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let same = (speed - 1.0).abs() < 1e-9;
|
||||
for i in 0..n {
|
||||
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
|
||||
if j * 2 + 1 >= buf.len() {
|
||||
break;
|
||||
}
|
||||
out[(a + i) * 2] += buf[j * 2] * ll;
|
||||
out[(a + i) * 2 + 1] += buf[j * 2 + 1] * lr;
|
||||
let buf = env.layer_audio(l, s0 + a as i64, b - a);
|
||||
for (i, f) in buf.chunks_exact(2).enumerate() {
|
||||
out[(a + i) * 2] += f[0] * ll;
|
||||
out[(a + i) * 2 + 1] += f[1] * lr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(project: &Project, comp_id: ItemId, comp: &Comp, l: &Layer, t: Tick, expr: Option<&dyn ExprHost>) -> (f32, f32) {
|
||||
fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) {
|
||||
let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) };
|
||||
let mut ctx = EvalCtx::new(project, comp_id, comp, t);
|
||||
ctx.expr = expr;
|
||||
let [a, b] = ctx.v2(l, g, "levels", [0.0, 0.0]);
|
||||
let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]);
|
||||
(db_to_gain(a), db_to_gain(b))
|
||||
}
|
||||
|
||||
/// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per
|
||||
/// `bin` sample frames, `[min L, max L, min R, max R]`.
|
||||
pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> {
|
||||
let bin = bin.max(1);
|
||||
samples
|
||||
.chunks(bin * 2)
|
||||
.map(|c| {
|
||||
let mut s = [f32::MAX, f32::MIN, f32::MAX, f32::MIN];
|
||||
for f in c.chunks_exact(2) {
|
||||
s[0] = s[0].min(f[0]);
|
||||
s[1] = s[1].max(f[0]);
|
||||
s[2] = s[2].min(f[1]);
|
||||
s[3] = s[3].max(f[1]);
|
||||
}
|
||||
if s[0] > s[1] { [0.0; 4] } else { s }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Peak summary of a footage item's audio from source time 0 over `duration`, `bins_per_sec`
|
||||
/// entries per second (rendered at a low internal rate, fetched in one-second chunks).
|
||||
pub fn footage_peaks(footage: &dyn FootageSource, item: ItemId, f: &effectcraft_project::Footage, duration: Tick, bins_per_sec: u32) -> Vec<[f32; 4]> {
|
||||
let bins_per_sec = bins_per_sec.max(1);
|
||||
// Enough samples per bin to catch peaks of audible content, without decoding at full rate.
|
||||
let bin = (64usize).max(4000 / bins_per_sec as usize);
|
||||
let rate = bin as u32 * bins_per_sec;
|
||||
let chunk = bin * bins_per_sec as usize;
|
||||
let total = units_ceil(duration, rate).max(0) as usize;
|
||||
let mut out = Vec::with_capacity(total / bin + 1);
|
||||
let mut i = 0usize;
|
||||
while i < total {
|
||||
let n = chunk.min(total - i);
|
||||
let buf = footage.audio(item, f, sample_tick(i as i64, rate), n, rate).unwrap_or_else(|| vec![0.0; n * 2]);
|
||||
out.extend(peak_summary(&buf, bin));
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
|
|
@ -209,6 +209,41 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
|||
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
|
||||
/// layers…).
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
|
||||
// Time effects see property values at other times. Keyframes are part of the hashed
|
||||
// structure (and the layer time is folded in), but expressions may read other layers at
|
||||
// those times, which the key cannot see: such layers are not cached.
|
||||
if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) {
|
||||
return None;
|
||||
}
|
||||
key_with(ctx, layer, scale, draft, false)
|
||||
}
|
||||
|
||||
/// Cache key for a layer's *input* at the context time: source → masks → its first `effects`
|
||||
/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here
|
||||
/// (keyed by item and source time), since Time effects read many neighbouring frames.
|
||||
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option<u64> {
|
||||
let base = key_with(ctx, layer, scale, draft, true)?;
|
||||
let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3);
|
||||
effects.hash(&mut h);
|
||||
Some(h.finish())
|
||||
}
|
||||
|
||||
/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)?
|
||||
fn reads_other_times(layer: &Layer) -> bool {
|
||||
layer.switches.effects
|
||||
&& layer.effects().is_some_and(|fx| {
|
||||
fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time.")))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_expression(g: &PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => p.has_expression(),
|
||||
Node::Group(sub) => has_expression(sub),
|
||||
})
|
||||
}
|
||||
|
||||
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option<u64> {
|
||||
if layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -220,6 +255,13 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio
|
|||
hash_debug(&mut h, s);
|
||||
}
|
||||
LayerSource::Text | LayerSource::Shape => {}
|
||||
LayerSource::Footage { item } if footage => {
|
||||
let it = ctx.project.item(*item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
hash_debug(&mut h, f);
|
||||
item.hash(&mut h);
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
ctx.comp_id.hash(&mut h);
|
||||
|
|
|
|||
|
|
@ -44,12 +44,17 @@ struct FxHost<'r, 'a, 'c> {
|
|||
r: &'r Renderer<'a>,
|
||||
ctx: &'c EvalCtx<'a>,
|
||||
layer: &'c Layer,
|
||||
/// Index of the effect being rendered (bounds `self_at` to effects before it).
|
||||
index: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
/// Nesting limit for effects that render layers (other layers, other times).
|
||||
const MAX_FX_DEPTH: usize = 8;
|
||||
|
||||
impl EffectHost for FxHost<'_, '_, '_> {
|
||||
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > 8 {
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
|
|
@ -70,6 +75,29 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
|||
let st = other.layer_time(Tick::from_seconds_f64(start));
|
||||
self.r.footage.audio(*item, f, st, frames, rate)
|
||||
}
|
||||
|
||||
fn self_at(&self, layer_time: f64, effects: usize) -> Option<Buf> {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
}
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() };
|
||||
let size = source_size(self.r.project, other);
|
||||
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
Some(LayerPixels { buf, size })
|
||||
}
|
||||
}
|
||||
|
||||
/// No footage available (renders footage layers as transparent).
|
||||
|
|
@ -187,10 +215,19 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
|
||||
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
|
||||
self.apply_effects_timed(ctx, layer, buf, adjustment, None)
|
||||
self.apply_effects_timed(ctx, layer, buf, adjustment, usize::MAX, None)
|
||||
}
|
||||
|
||||
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf {
|
||||
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count).
|
||||
fn apply_effects_timed(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
mut buf: Buf,
|
||||
adjustment: bool,
|
||||
limit: usize,
|
||||
mut timing: Option<&mut Vec<(String, f64)>>,
|
||||
) -> Buf {
|
||||
if !layer.switches.effects {
|
||||
return buf;
|
||||
}
|
||||
|
|
@ -199,15 +236,24 @@ impl<'a> Renderer<'a> {
|
|||
let size = source_size(self.project, layer);
|
||||
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
let mask_shapes = masks::shapes(ctx, layer);
|
||||
let host = FxHost { r: self, ctx, layer };
|
||||
let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() };
|
||||
for g in fx.groups() {
|
||||
let host = FxHost { r: self, ctx, layer, index: Default::default() };
|
||||
let env =
|
||||
EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 };
|
||||
for (i, g) in fx.groups().enumerate() {
|
||||
if i >= limit {
|
||||
break;
|
||||
}
|
||||
if !g.enabled {
|
||||
continue;
|
||||
}
|
||||
let GroupKind::Effect { effect } = &g.kind else { continue };
|
||||
let Some(spec) = effectcraft_effects::find(effect) else { continue };
|
||||
if effectcraft_effects::audio_fx::is_audio_effect(spec.id) {
|
||||
continue;
|
||||
}
|
||||
let params = self.effect_params(ctx, layer, g);
|
||||
host.index.store(i, std::sync::atomic::Ordering::Relaxed);
|
||||
let env = EffectEnv { effect_index: i, ..env };
|
||||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||||
let t0 = std::time::Instant::now();
|
||||
buf = effectcraft_effects::apply(spec, &ectx, buf);
|
||||
|
|
@ -300,13 +346,35 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects)));
|
||||
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
Some((buf, key))
|
||||
}
|
||||
|
||||
/// A layer's input at the context time: source → masks → its first `effects` effects
|
||||
/// (what Time effects read at neighbouring times). Served from / stored in the layer cache
|
||||
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
|
||||
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
|
||||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.opts.scale, self.opts.draft, effects));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
return Some(b);
|
||||
}
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
if effects > 0 {
|
||||
buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None);
|
||||
}
|
||||
let buf = Arc::new(buf);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// The layer with its styles: exterior passes and body (cached per pass).
|
||||
fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<StyledLayer> {
|
||||
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
|
||||
|
|
@ -567,3 +635,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti
|
|||
pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
|
||||
effectcraft_path::to_kurbo(sp)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests_audio_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_time;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
//! Audio effects through the mixdown (the path preview playback and export share).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::audio::{comp_has_audio, footage_peaks, mix_comp, peak_summary};
|
||||
use crate::{FootageSource, Image};
|
||||
|
||||
const SR: u32 = 8000;
|
||||
|
||||
/// Synthetic footage audio as a function of source time (seconds) → (L, R).
|
||||
struct Signal(fn(f64) -> (f32, f32));
|
||||
impl FootageSource for Signal {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
let t0 = start.seconds();
|
||||
Some(
|
||||
(0..frames)
|
||||
.flat_map(|i| {
|
||||
let t = t0 + i as f64 / rate as f64;
|
||||
let (l, r) = if (0.0..2.0).contains(&t) { (self.0)(t) } else { (0.0, 0.0) };
|
||||
[l, r]
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_footage() -> Footage {
|
||||
Footage {
|
||||
path: "x.wav".into(),
|
||||
kind: FootageKind::Audio,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::new(25, 1),
|
||||
native_rate: None,
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
has_video: false,
|
||||
has_audio: true,
|
||||
alpha: Default::default(),
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2 s comp with one audio layer (in 0, out 1 s) carrying effect `id` with `vals`.
|
||||
fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
let src = if solid {
|
||||
let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 64, height: 64, pixel_aspect: 1.0 }));
|
||||
LayerSource::Solid { item: sid }
|
||||
} else {
|
||||
LayerSource::Footage { item: p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage())) }
|
||||
};
|
||||
let mut l = build::layer(&mut p, &comp, "a", src, (64, 64), None);
|
||||
l.out_point = Tick::from_seconds_f64(1.0);
|
||||
if let Some(id) = id {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]);
|
||||
p.next_id = next;
|
||||
for (k, v) in vals {
|
||||
g.prop_mut(k).unwrap_or_else(|| panic!("{k}")).value = v.clone();
|
||||
}
|
||||
l.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
let mut comp = comp;
|
||||
comp.layers = vec![l];
|
||||
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn mix(p: &Project, src: &dyn FootageSource, cid: ItemId, from: f64, frames: usize) -> Vec<f32> {
|
||||
mix_comp(p, src, None, cid, Tick::from_seconds_f64(from), frames, SR)
|
||||
}
|
||||
|
||||
fn ramp(t: f64) -> (f32, f32) {
|
||||
(t as f32, -(t as f32))
|
||||
}
|
||||
|
||||
fn rms(m: &[f32]) -> f32 {
|
||||
(m.iter().map(|v| v * v).sum::<f32>() / m.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backwards_reverses_the_layer_span() {
|
||||
let (p, cid) = comp_with(Some("ec.audio.backwards"), &[], false);
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
// Comp time t plays source time ≈ 1 − t.
|
||||
for i in [0usize, 2000, 4000, 7999] {
|
||||
let t = i as f64 / SR as f64;
|
||||
assert!((m[i * 2] as f64 - (1.0 - t)).abs() < 2.0 / SR as f64, "i={i}: {}", m[i * 2]);
|
||||
}
|
||||
let (p, cid) = comp_with(None, &[], false);
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
assert!((m[4000 * 2] - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_low_pass_attenuates() {
|
||||
fn hi(t: f64) -> (f32, f32) {
|
||||
let v = (2.0 * std::f64::consts::PI * 3000.0 * t).sin() as f32;
|
||||
(v, v)
|
||||
}
|
||||
let vals = [("filterOptions", Value::Enum(1)), ("cutoffFrequency", Value::Scalar(200.0))];
|
||||
let (p, cid) = comp_with(Some("ec.audio.highlowpass"), &vals, false);
|
||||
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
|
||||
assert!(r < 0.02, "{r}");
|
||||
let (p, cid) = comp_with(None, &[], false);
|
||||
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
|
||||
assert!(r > 0.6, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_offsets_across_blocks() {
|
||||
fn click(t: f64) -> (f32, f32) {
|
||||
if (t * SR as f64).round() as i64 == 2000 { (1.0, 1.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
let vals = [("delayTime", Value::Scalar(100.0)), ("feedback", Value::Scalar(0.0))];
|
||||
let (p, cid) = comp_with(Some("ec.audio.delay"), &vals, false);
|
||||
// The block starts after the click (0.25 s): the echo at 0.35 s still comes from pre-roll.
|
||||
let m = mix(&p, &Signal(click), cid, 0.3, 800);
|
||||
let i = 2800 - 2400;
|
||||
assert!((m[i * 2] - 0.375).abs() < 1e-5, "{}", m[i * 2]);
|
||||
assert_eq!(m.iter().filter(|v| v.abs() > 1e-6).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_mixer_pans() {
|
||||
fn left(_: f64) -> (f32, f32) {
|
||||
(0.5, 0.0)
|
||||
}
|
||||
let (p, cid) = comp_with(Some("ec.audio.stereomixer"), &[("leftPan", Value::Scalar(100.0))], false);
|
||||
let m = mix(&p, &Signal(left), cid, 0.2, 10);
|
||||
assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_makes_a_solid_audible_at_its_frequency() {
|
||||
let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))];
|
||||
for k in ["frequency2", "frequency3", "frequency4", "frequency5"] {
|
||||
vals.push((k, Value::Scalar(0.0)));
|
||||
}
|
||||
let (p, cid) = comp_with(Some("ec.audio.tone"), &vals, true);
|
||||
assert!(comp_has_audio(&p, cid));
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
let left: Vec<f32> = m.iter().step_by(2).copied().collect();
|
||||
let ups = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
|
||||
assert!((ups as i64 - 500).abs() <= 1, "{ups}");
|
||||
// The out point (1 s) silences it.
|
||||
let after = mix(&p, &Signal(ramp), cid, 1.0, 100);
|
||||
assert!(after.iter().all(|v| *v == 0.0));
|
||||
let (p, cid) = comp_with(None, &[], true);
|
||||
assert!(!comp_has_audio(&p, cid));
|
||||
}
|
||||
|
||||
/// Mixing in video-frame blocks gives the same samples as one long block (pre-roll makes
|
||||
/// effects stateless across calls), so preview and export agree.
|
||||
#[test]
|
||||
fn blocks_match_one_long_mix() {
|
||||
fn noise(t: f64) -> (f32, f32) {
|
||||
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
|
||||
(x, -x)
|
||||
}
|
||||
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
|
||||
let vals: Vec<(&str, Value)> = match id {
|
||||
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
|
||||
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
|
||||
_ => vec![],
|
||||
};
|
||||
let (p, cid) = comp_with(Some(id), &vals, false);
|
||||
let long = mix(&p, &Signal(noise), cid, 0.2, 1600);
|
||||
let mut blocks = vec![];
|
||||
for k in 0..5 {
|
||||
blocks.extend(mix(&p, &Signal(noise), cid, 0.2 + k as f64 * 0.04, 320));
|
||||
}
|
||||
let worst = long.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 2e-3, "{id}: {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_summary_bins() {
|
||||
let s: Vec<f32> = (0..10).flat_map(|i| [i as f32 * 0.1, -(i as f32) * 0.1]).collect();
|
||||
let p = peak_summary(&s, 4);
|
||||
assert_eq!(p.len(), 3);
|
||||
assert!(p[0][0].abs() < 1e-6 && (p[0][1] - 0.3).abs() < 1e-6);
|
||||
assert!((p[1][2] + 0.7).abs() < 1e-6 && (p[1][3] + 0.4).abs() < 1e-6);
|
||||
let f = audio_footage();
|
||||
let peaks = footage_peaks(&Signal(ramp), ItemId(1), &f, Tick::from_seconds_f64(2.0), 100);
|
||||
assert_eq!(peaks.len(), 200);
|
||||
assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
//! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the
|
||||
//! layer cache staying correct while scrubbing.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
fn render(p: &Project, cid: ItemId, t: f64, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
|
||||
r.cache = cache;
|
||||
r.comp_frame(cid, Tick::from_seconds_f64(t))
|
||||
}
|
||||
|
||||
/// A white 200×100 solid whose full-frame mask fades in (opacity 0 → 100 over 0..1 s), with
|
||||
/// effect `fx` (and parameter overrides) applied.
|
||||
fn scene(fx: Option<(&str, &[(&str, Value)])>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None);
|
||||
let mut next = p.next_id;
|
||||
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([100.0, 50.0], 400.0, 200.0), MaskMode::Add, [255, 255, 0]);
|
||||
if let Some(o) = m.prop_mut("opacity") {
|
||||
o.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
|
||||
}
|
||||
l.props.sub_mut("masks").unwrap().children.push(m.into());
|
||||
if let Some((id, vals)) = fx {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
|
||||
for (k, v) in vals {
|
||||
g.prop_mut(k).unwrap().value = v.clone();
|
||||
}
|
||||
l.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
p.next_id = next;
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn alpha(img: &crate::Image) -> f32 {
|
||||
img.get(100, 50)[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posterize_time_holds_frames() {
|
||||
let (p, cid) = scene(Some(("ec.time.posterizetime", &[("frameRate", Value::Scalar(4.0))])));
|
||||
for (t, want) in [(0.3, 0.25), (0.45, 0.25), (0.5, 0.5), (0.74, 0.5), (0.8, 0.75)] {
|
||||
let a = alpha(&render(&p, cid, t, None));
|
||||
assert!((a - want).abs() < 0.01, "t={t}: {a}");
|
||||
}
|
||||
let (plain, cid) = scene(None);
|
||||
assert!((alpha(&render(&plain, cid, 0.3, None)) - 0.3).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_blends_neighbouring_frames() {
|
||||
let vals = [("echoTime", Value::Scalar(-0.1)), ("numberOfEchoes", Value::Scalar(2.0)), ("decay", Value::Scalar(0.5))];
|
||||
let (p, cid) = scene(Some(("ec.time.echo", &vals)));
|
||||
let a = alpha(&render(&p, cid, 0.3, None));
|
||||
let want = 0.3 + 0.5 * 0.2 + 0.25 * 0.1;
|
||||
assert!((a - want).abs() < 0.01, "{a} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_difference_of_static_layer_is_black() {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(64, 32, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.8, 0.4, 0.2], width: 64, height: 32, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (64, 32), None);
|
||||
let spec = effectcraft_effects::find("ec.time.timedifference").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 32.0]);
|
||||
g.prop_mut("timeOffset").unwrap().value = Value::Scalar(-0.5);
|
||||
p.next_id = next;
|
||||
l.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = render(&p, cid, 1.0, None);
|
||||
assert!(img.data.iter().all(|px| px[0] == 0.0 && px[1] == 0.0 && px[2] == 0.0 && px[3] == 1.0), "{:?}", img.get(10, 10));
|
||||
}
|
||||
|
||||
/// Scrubbing back and forth with the layer cache gives exactly the uncached frames (Echo's
|
||||
/// output depends on neighbouring frames, so its key must include the layer time), and an edit
|
||||
/// to the animation is picked up.
|
||||
#[test]
|
||||
fn cache_correct_while_scrubbing() {
|
||||
let vals = [("echoTime", Value::Scalar(-0.2)), ("numberOfEchoes", Value::Scalar(3.0)), ("decay", Value::Scalar(0.6)), ("echoOperator", Value::Enum(6))];
|
||||
let (mut p, cid) = scene(Some(("ec.time.echo", &vals)));
|
||||
let cache = crate::LayerCache::default();
|
||||
for t in [0.3, 0.5, 0.3, 1.2, 0.8, 0.5, 0.3, 1.5] {
|
||||
let a = render(&p, cid, t, Some(&cache));
|
||||
let b = render(&p, cid, t, None);
|
||||
assert_eq!(a.data, b.data, "t={t}");
|
||||
}
|
||||
let st = cache.stats();
|
||||
assert!(st.hits > 0, "{st:?}");
|
||||
// Re-key the fade (0 → 100 over 0..0.5 s): cached frames must not be served.
|
||||
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("masks/#1/opacity").unwrap().keys[1].time = Tick::from_seconds_f64(0.5);
|
||||
for t in [0.3, 0.5, 0.8] {
|
||||
assert_eq!(render(&p, cid, t, Some(&cache)).data, render(&p, cid, t, None).data, "edited t={t}");
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +114,25 @@ fn main() {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
// Pointer input straight into the harness (menus and popups need real input events):
|
||||
// {"method":"pointer","params":{"x":237,"y":12,"click":true}}
|
||||
if method == "pointer" {
|
||||
let pos = egui::pos2(params["x"].as_f64().unwrap_or(0.0) as f32, params["y"].as_f64().unwrap_or(0.0) as f32);
|
||||
harness.input_mut().events.push(egui::Event::PointerMoved(pos));
|
||||
if params["click"].as_bool() == Some(true) {
|
||||
for pressed in [true, false] {
|
||||
harness.input_mut().events.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
settle(&mut harness, 0.3);
|
||||
println!("{}", json!({"method": method, "reply": {"ok": true}}));
|
||||
continue;
|
||||
}
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx });
|
||||
// Requests are answered on a later frame (some wait for input to be processed).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
//! Real-time audio preview.
|
||||
//!
|
||||
//! The frontend owns no audio API: the native app supplies an [`AudioDevice`] (cpal) through
|
||||
//! [`crate::Hooks::audio_device`]. During preview playback an [`AudioPlayback`] runs a feeder
|
||||
//! thread that mixes the composition ahead of the device with the same mixdown export uses
|
||||
//! (`effectcraft_render::audio::mix_comp`: Audio switch, solo, Audio Levels, audio effects) and
|
||||
//! pushes it into an [`AudioFeed`]; the device callback pulls from the feed.
|
||||
//!
|
||||
//! **A/V sync:** while audio plays, the audio clock drives playback. The clock is the number of
|
||||
//! sample frames the device has actually consumed (underruns do not advance it) minus the
|
||||
//! device's output latency, mapped through the work-area loop ([`clock_sample`]); the viewer
|
||||
//! shows the comp frame at that sample ([`frame_of_sample`]), dropping video frames rather
|
||||
//! than drifting.
|
||||
//!
|
||||
//! The feed also tracks per-channel peaks for the Audio panel's VU meters ([`Meter`]).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_engine::time::{FrameRate, Tick};
|
||||
|
||||
use crate::frames::RenderSource;
|
||||
|
||||
/// An audio output (implemented by the native app with cpal).
|
||||
pub trait AudioDevice: Send {
|
||||
/// Device sample rate in Hz.
|
||||
fn sample_rate(&self) -> u32;
|
||||
/// Start pulling interleaved stereo frames from `feed` (see [`AudioFeed::pull`]).
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String>;
|
||||
fn stop(&mut self);
|
||||
/// Output latency in sample frames (device buffering), for A/V sync.
|
||||
fn latency_frames(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the default output device (`None` when there is none).
|
||||
pub type AudioDeviceFactory = Box<dyn Fn() -> Option<Box<dyn AudioDevice>>>;
|
||||
|
||||
/// Lock-protected sample queue between the feeder thread and the device callback.
|
||||
#[derive(Default)]
|
||||
pub struct AudioFeed {
|
||||
queue: Mutex<VecDeque<f32>>,
|
||||
/// Sample frames handed to the device (real audio only: underruns do not count).
|
||||
consumed: AtomicU64,
|
||||
/// Peak |sample| per channel since the last [`AudioFeed::take_peaks`] (f32 bits).
|
||||
peak: [AtomicU32; 2],
|
||||
/// The feeder reached the end of a non-looping span.
|
||||
finished: AtomicBool,
|
||||
}
|
||||
|
||||
impl AudioFeed {
|
||||
/// Fill `out` (interleaved stereo) from the queue; silence where the queue runs dry.
|
||||
pub fn pull(&self, out: &mut [f32]) {
|
||||
let mut real = 0usize;
|
||||
let mut pk = [0.0f32; 2];
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
let n = q.len().min(out.len()) & !1;
|
||||
for (i, (o, s)) in out.iter_mut().zip(q.drain(..n)).enumerate() {
|
||||
*o = s;
|
||||
pk[i & 1] = pk[i & 1].max(s.abs());
|
||||
}
|
||||
real = n;
|
||||
}
|
||||
out[real..].iter_mut().for_each(|o| *o = 0.0);
|
||||
self.consumed.fetch_add((real / 2) as u64, Ordering::Relaxed);
|
||||
for (c, v) in pk.into_iter().enumerate() {
|
||||
let _ = self.peak[c].fetch_update(Ordering::Relaxed, Ordering::Relaxed, |old| (v > f32::from_bits(old)).then_some(v.to_bits()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, samples: &[f32]) {
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
q.extend(samples.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample frames waiting in the queue.
|
||||
pub fn queued_frames(&self) -> usize {
|
||||
self.queue.lock().map(|q| q.len() / 2).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn consumed(&self) -> u64 {
|
||||
self.consumed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Peak |sample| per channel since the last call (resets them).
|
||||
pub fn take_peaks(&self) -> [f32; 2] {
|
||||
[f32::from_bits(self.peak[0].swap(0, Ordering::Relaxed)), f32::from_bits(self.peak[1].swap(0, Ordering::Relaxed))]
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.finished.load(Ordering::Relaxed) && self.queued_frames() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// What a preview plays, in comp sample indices at the device rate.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PlaySpan {
|
||||
pub start: i64,
|
||||
/// Work area [wa, wb).
|
||||
pub wa: i64,
|
||||
pub wb: i64,
|
||||
pub looping: bool,
|
||||
}
|
||||
|
||||
impl PlaySpan {
|
||||
/// Span for comp times: start, work area [wa, wb).
|
||||
pub fn new(start: Tick, wa: Tick, wb: Tick, looping: bool, rate: u32) -> PlaySpan {
|
||||
let r = rate as i64;
|
||||
PlaySpan { start: start.to_units_floor(r), wa: wa.to_units_floor(r), wb: wb.to_units_floor(r).max(wa.to_units_floor(r) + 1), looping }
|
||||
}
|
||||
}
|
||||
|
||||
/// The comp sample index audible now: `consumed` frames played by the device minus its
|
||||
/// `latency`, from the span start, wrapped into the work area when looping (clamped at its end
|
||||
/// otherwise).
|
||||
pub fn clock_sample(span: &PlaySpan, consumed: u64, latency: u64) -> i64 {
|
||||
let s = span.start + consumed.saturating_sub(latency) as i64;
|
||||
if s < span.wb {
|
||||
return s;
|
||||
}
|
||||
if span.looping { span.wa + (s - span.wb).rem_euclid((span.wb - span.wa).max(1)) } else { span.wb - 1 }
|
||||
}
|
||||
|
||||
/// Comp time of sample index `s` at `rate` Hz.
|
||||
pub fn sample_time(s: i64, rate: u32) -> Tick {
|
||||
Tick::from_units(s, rate.max(1) as i64)
|
||||
}
|
||||
|
||||
/// The comp frame showing sample `s` (the frame whose interval contains it).
|
||||
pub fn frame_of_sample(s: i64, rate: u32, fps: FrameRate) -> i64 {
|
||||
fps.frame_at(sample_time(s, rate))
|
||||
}
|
||||
|
||||
/// Block size the feeder mixes at a time.
|
||||
const BLOCK: usize = 1024;
|
||||
|
||||
/// A running audio preview: device + feeder thread.
|
||||
pub struct AudioPlayback {
|
||||
pub feed: Arc<AudioFeed>,
|
||||
device: Box<dyn AudioDevice>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
pub rate: u32,
|
||||
pub span: PlaySpan,
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Start mixing `comp` over `span` into `device`.
|
||||
pub fn start(
|
||||
mut device: Box<dyn AudioDevice>,
|
||||
src: RenderSource,
|
||||
comp: ItemId,
|
||||
start: Tick,
|
||||
wa: Tick,
|
||||
wb: Tick,
|
||||
looping: bool,
|
||||
) -> Result<AudioPlayback, String> {
|
||||
let rate = device.sample_rate().max(8000);
|
||||
let span = PlaySpan::new(start, wa, wb, looping, rate);
|
||||
let feed = Arc::new(AudioFeed::default());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
// Prime ~50 ms so the device does not start on an empty queue.
|
||||
let mut cursor = span.start;
|
||||
let prime = (rate as usize / 20).div_ceil(BLOCK);
|
||||
for _ in 0..prime {
|
||||
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
|
||||
}
|
||||
let thread = {
|
||||
let (feed, stop) = (feed.clone(), stop.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("ec-audio-feed".into())
|
||||
.spawn(move || {
|
||||
// Keep ~200 ms queued ahead of the device.
|
||||
let target = rate as usize / 5;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
|
||||
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
|
||||
} else {
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
device.start(feed.clone())?;
|
||||
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), rate, span })
|
||||
}
|
||||
|
||||
/// The comp sample index audible now.
|
||||
pub fn clock(&self) -> i64 {
|
||||
clock_sample(&self.span, self.feed.consumed(), self.device.latency_frames())
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.feed.finished()
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.device.stop();
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayback {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at
|
||||
/// the work-area end).
|
||||
fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32) -> i64 {
|
||||
let mut cursor = cursor;
|
||||
if cursor >= span.wb {
|
||||
if !span.looping {
|
||||
feed.finished.store(true, Ordering::Relaxed);
|
||||
return cursor;
|
||||
}
|
||||
cursor = span.wa;
|
||||
}
|
||||
let n = ((span.wb - cursor) as usize).min(BLOCK);
|
||||
let buf = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, sample_time(cursor, rate), n, rate);
|
||||
feed.push(&buf);
|
||||
cursor + n as i64
|
||||
}
|
||||
|
||||
/// VU meter state for the Audio panel: smoothed level with a falling decay and peak hold, in
|
||||
/// dBFS per channel.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Meter {
|
||||
pub level_db: [f32; 2],
|
||||
pub peak_db: [f32; 2],
|
||||
peak_at: [f64; 2],
|
||||
/// Clip indicators (latched until clicked / playback restarts).
|
||||
pub clipped: [bool; 2],
|
||||
last: f64,
|
||||
}
|
||||
|
||||
impl Default for Meter {
|
||||
fn default() -> Self {
|
||||
Meter { level_db: [METER_FLOOR; 2], peak_db: [METER_FLOOR; 2], peak_at: [0.0; 2], clipped: [false; 2], last: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom of the meter scale (dB).
|
||||
pub const METER_FLOOR: f32 = -48.0;
|
||||
/// How long the peak marker holds (s) and how fast levels fall (dB/s).
|
||||
const PEAK_HOLD: f64 = 1.5;
|
||||
const FALL_DB_PER_S: f32 = 30.0;
|
||||
|
||||
pub fn to_db(v: f32) -> f32 {
|
||||
if v <= 1e-6 { METER_FLOOR } else { (20.0 * v.log10()).max(METER_FLOOR) }
|
||||
}
|
||||
|
||||
impl Meter {
|
||||
/// Feed the peaks measured since the last update at wall time `now` (seconds).
|
||||
pub fn update(&mut self, peaks: [f32; 2], now: f64) {
|
||||
let dt = if self.last > 0.0 { (now - self.last).clamp(0.0, 0.5) as f32 } else { 0.0 };
|
||||
self.last = now;
|
||||
for c in 0..2 {
|
||||
let db = to_db(peaks[c]);
|
||||
self.level_db[c] = db.max(self.level_db[c] - FALL_DB_PER_S * dt);
|
||||
if db >= self.peak_db[c] || now - self.peak_at[c] > PEAK_HOLD {
|
||||
self.peak_db[c] = db.max(self.level_db[c]);
|
||||
self.peak_at[c] = now;
|
||||
}
|
||||
if peaks[c] >= 1.0 {
|
||||
self.clipped[c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything to animate (levels still falling)?
|
||||
pub fn active(&self) -> bool {
|
||||
self.level_db.iter().chain(self.peak_db.iter()).any(|d| *d > METER_FLOOR)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clock_follows_consumed_frames_and_loops() {
|
||||
let span = PlaySpan { start: 4800, wa: 0, wb: 48000, looping: true };
|
||||
assert_eq!(clock_sample(&span, 0, 0), 4800);
|
||||
// Latency holds the clock back until the device has played through its buffer.
|
||||
assert_eq!(clock_sample(&span, 512, 1024), 4800);
|
||||
assert_eq!(clock_sample(&span, 2048, 1024), 4800 + 1024);
|
||||
// Wrap at the work-area end.
|
||||
assert_eq!(clock_sample(&span, 48000 - 4800 + 100, 0), 100);
|
||||
let once = PlaySpan { looping: false, ..span };
|
||||
assert_eq!(clock_sample(&once, 100_000, 0), 47999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_mapping_matches_comp_frames() {
|
||||
let fps = FrameRate::new(30, 1);
|
||||
// 48 kHz at 30 fps: 1600 samples per frame.
|
||||
assert_eq!(frame_of_sample(0, 48000, fps), 0);
|
||||
assert_eq!(frame_of_sample(1599, 48000, fps), 0);
|
||||
assert_eq!(frame_of_sample(1600, 48000, fps), 1);
|
||||
assert_eq!(frame_of_sample(48000 * 2, 48000, fps), 60);
|
||||
let span = PlaySpan::new(Tick::from_seconds_f64(1.0), Tick::ZERO, Tick::from_seconds_f64(2.0), true, 48000);
|
||||
assert_eq!((span.start, span.wa, span.wb), (48000, 0, 96000));
|
||||
// 29.97: frame boundaries are fractional in samples.
|
||||
let ntsc = FrameRate::new(30000, 1001);
|
||||
assert_eq!(frame_of_sample(1601, 48000, ntsc), 0);
|
||||
assert_eq!(frame_of_sample(1602, 48000, ntsc), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_counts_real_frames_and_peaks() {
|
||||
let f = AudioFeed::default();
|
||||
f.push(&[0.5, -0.25, 0.1, 0.9]);
|
||||
let mut out = [1.0f32; 8];
|
||||
f.pull(&mut out);
|
||||
assert_eq!(out, [0.5, -0.25, 0.1, 0.9, 0.0, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(f.consumed(), 2);
|
||||
assert_eq!(f.take_peaks(), [0.5, 0.9]);
|
||||
assert_eq!(f.take_peaks(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_decays_and_holds_peaks() {
|
||||
let mut m = Meter::default();
|
||||
m.update([1.0, 0.5], 1.0);
|
||||
assert!((m.level_db[0] - 0.0).abs() < 1e-4 && (m.level_db[1] + 6.0206).abs() < 1e-3);
|
||||
assert!(m.clipped[0] && !m.clipped[1]);
|
||||
m.update([0.0, 0.0], 1.1);
|
||||
assert!((m.level_db[0] + 3.0).abs() < 1e-3, "{}", m.level_db[0]);
|
||||
assert_eq!(m.peak_db[0], 0.0, "peak holds");
|
||||
// After the hold time the peak marker follows the falling level.
|
||||
m.update([0.0, 0.0], 3.0);
|
||||
assert!(m.peak_db[0] < -10.0, "{}", m.peak_db[0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -304,9 +304,12 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
match s("action").unwrap_or("toggle") {
|
||||
"play" => app.play(now),
|
||||
"stop" => app.stop(),
|
||||
"status" => {}
|
||||
_ => app.toggle_play(now),
|
||||
}
|
||||
ok(json!({"playing": app.playback.playing, "time": app.session.time().seconds()}))
|
||||
ok(
|
||||
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
|
||||
)
|
||||
}
|
||||
"ui.screenshot" => {
|
||||
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! Everything goes through `effectcraft_engine::Session::execute` (or `menus::invoke` for
|
||||
//! frontend-only commands) so every gesture is also available to agents.
|
||||
|
||||
pub mod audio;
|
||||
pub mod automation;
|
||||
pub mod control;
|
||||
pub mod dock;
|
||||
|
|
@ -41,6 +42,16 @@ pub enum Dialog {
|
|||
CompSettings,
|
||||
SolidSettings,
|
||||
CommandPalette,
|
||||
/// Settings (Preferences) on `dialog_state.settings_page`.
|
||||
Settings,
|
||||
/// Edit ▸ Keyboard Shortcuts.
|
||||
Shortcuts,
|
||||
/// A generic parameter form for a command (`dialog_state.form`).
|
||||
Form,
|
||||
/// A message (`dialog_state.info`).
|
||||
Info,
|
||||
/// View ▸ View Options.
|
||||
ViewOptions,
|
||||
CameraSettings,
|
||||
LightSettings,
|
||||
KeyVelocity,
|
||||
|
|
@ -54,6 +65,8 @@ pub struct Hooks {
|
|||
pub pick_files: Option<Box<dyn Fn(&[&str]) -> Vec<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Opens the audio output for preview playback (the desktop app uses cpal).
|
||||
pub audio_device: Option<audio::AudioDeviceFactory>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -101,6 +114,14 @@ pub struct EffectcraftApp {
|
|||
pub(crate) pointer_comp: Option<[f32; 2]>,
|
||||
pub(crate) dialog_state: panels::dialogs::DialogState,
|
||||
pub integrated_titlebar: bool,
|
||||
/// Audio preview while playing (audio clock drives playback).
|
||||
pub audio: Option<audio::AudioPlayback>,
|
||||
/// Audio panel VU meters.
|
||||
pub meter: audio::Meter,
|
||||
/// Waveform peak summaries per footage item (see `panels::waveform`).
|
||||
pub(crate) waveforms: panels::waveform::Cache,
|
||||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
|
|
@ -133,6 +154,10 @@ impl EffectcraftApp {
|
|||
pointer_comp: None,
|
||||
dialog_state: Default::default(),
|
||||
integrated_titlebar: false,
|
||||
audio: None,
|
||||
meter: Default::default(),
|
||||
waveforms: Default::default(),
|
||||
last_reveal: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,10 +269,32 @@ impl EffectcraftApp {
|
|||
waiting: false,
|
||||
fps: c.frame_rate.as_f64(),
|
||||
};
|
||||
self.start_audio(t);
|
||||
}
|
||||
|
||||
/// Start audio preview from comp time `t` when the Preview panel includes audio, the comp
|
||||
/// has something audible and an output device opens.
|
||||
fn start_audio(&mut self, t: Tick) {
|
||||
self.audio = None;
|
||||
self.meter.clipped = [false; 2];
|
||||
if !self.ui.preview_audio {
|
||||
return;
|
||||
}
|
||||
let Some(cid) = self.session.active_comp_id() else { return };
|
||||
let Some(c) = self.session.project.comp(cid).cloned() else { return };
|
||||
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
|
||||
return;
|
||||
}
|
||||
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f()) else { return };
|
||||
match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) {
|
||||
Ok(a) => self.audio = Some(a),
|
||||
Err(e) => log::warn!("audio preview: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.playback.playing = false;
|
||||
self.audio = None;
|
||||
}
|
||||
|
||||
pub fn toggle_play(&mut self, now: f64) {
|
||||
|
|
@ -291,9 +338,32 @@ impl EffectcraftApp {
|
|||
queued += 1;
|
||||
}
|
||||
}
|
||||
if let Some(a) = &self.audio {
|
||||
self.meter.update(a.feed.take_peaks(), now);
|
||||
} else if self.meter.active() {
|
||||
self.meter.update([0.0; 2], now);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if !self.playback.playing {
|
||||
return;
|
||||
}
|
||||
// Audio clock drives playback: show the frame under the audible sample (frames that
|
||||
// are not rendered yet are dropped, never delayed).
|
||||
if let Some(a) = &self.audio {
|
||||
if a.finished() {
|
||||
self.session.set_time(fr.tick_of(wb));
|
||||
self.stop();
|
||||
return;
|
||||
}
|
||||
let target = audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb);
|
||||
self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale));
|
||||
if target != cur {
|
||||
self.session.set_time(fr.tick_of(target));
|
||||
self.playback.shown += 1;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
let elapsed = now - self.playback.start_wall;
|
||||
let mut target = self.playback.start_frame + (elapsed * fr.as_f64()).floor() as i64;
|
||||
if target > wb {
|
||||
|
|
@ -444,6 +514,12 @@ impl EffectcraftApp {
|
|||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
}
|
||||
effectcraft_engine::Event::PurgeCaches => self.frames.clear(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
//! Menus, keyboard shortcuts and UI-level commands. The menu bar is generated from the engine
|
||||
//! registry plus the UI command table (commands that only affect the frontend: tools, playback,
|
||||
//! viewer zoom, panels, reveal shortcuts). `invoke` is the single entry point used by menus,
|
||||
//! shortcuts and the control channel.
|
||||
//! Menus, keyboard shortcuts and UI-level commands. The menu bar is the engine's After Effects
|
||||
//! menu tree (`effectcraft_engine::menus`): every entry is an engine command, and frontend-only
|
||||
//! commands (viewer zoom, panels, dialogs…) come back as `Event::Frontend` and are performed by
|
||||
//! [`frontend`]. [`UI_COMMANDS`] holds the remaining UI-only shortcuts (tools, timeline
|
||||
//! navigation, reveal keys). `invoke` is the single entry point used by menus, shortcuts and the
|
||||
//! control channel.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::dock::PanelKind;
|
||||
use crate::state::{Resolution, Tool};
|
||||
use effectcraft_engine::menus::{MenuEntry, MenuNode};
|
||||
|
||||
pub struct UiCommand {
|
||||
pub id: &'static str,
|
||||
|
|
@ -22,32 +25,23 @@ macro_rules! uic {
|
|||
};
|
||||
}
|
||||
|
||||
/// UI-only commands (not in the AE menus; shortcuts and the command palette).
|
||||
pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
uic!("playback.toggle", "Play/Stop", ["Composition", "Preview"], Some("Space")),
|
||||
uic!("playback.ramPreview", "Play Current Preview", ["Composition", "Preview"], Some("Num0")),
|
||||
uic!("playback.ramPreview", "Play Current Preview", [], Some("Num0")),
|
||||
uic!("playback.stop", "Stop", [], None),
|
||||
uic!("view.zoomIn", "Zoom In", ["View"], Some(".")),
|
||||
uic!("view.zoomOut", "Zoom Out", ["View"], Some(",")),
|
||||
uic!("view.fit", "Fit", ["View"], Some("Shift+/")),
|
||||
uic!("view.actualSize", "100%", ["View"], Some("/")),
|
||||
uic!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J")),
|
||||
uic!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J")),
|
||||
uic!("view.res.third", "Third", ["View", "Resolution"], None),
|
||||
uic!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J")),
|
||||
uic!("view.res.auto", "Auto", ["View", "Resolution"], None),
|
||||
uic!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R")),
|
||||
uic!("view.grid", "Show Grid", ["View"], Some("Cmd+'")),
|
||||
uic!("view.safeMargins", "Title/Action Safe", ["View"], None),
|
||||
uic!("view.transparencyGrid", "Transparency Grid", ["View"], None),
|
||||
uic!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H")),
|
||||
uic!("view.fastPreviews", "Fast Previews", ["View"], None),
|
||||
uic!("view.theme.dark", "Dark", ["View", "Appearance"], None),
|
||||
uic!("view.theme.darker", "Darker", ["View", "Appearance"], None),
|
||||
uic!("view.theme.light", "Light", ["View", "Appearance"], None),
|
||||
uic!("view.fit", "Fit", [], Some("Shift+/")),
|
||||
uic!("view.actualSize", "100%", [], Some("/")),
|
||||
uic!("view.res.auto", "Resolution: Auto", [], None),
|
||||
uic!("view.safeMargins", "Title/Action Safe", [], None),
|
||||
uic!("view.transparencyGrid", "Transparency Grid", [], None),
|
||||
uic!("view.fastPreviews", "Fast Previews", [], None),
|
||||
uic!("view.theme.dark", "Theme: Dark", [], None),
|
||||
uic!("view.theme.darker", "Theme: Darker", [], None),
|
||||
uic!("view.theme.light", "Theme: Light", [], None),
|
||||
uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")),
|
||||
uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")),
|
||||
uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some(";")),
|
||||
uic!("timeline.graphEditor", "Graph Editor", ["Window"], Some("Shift+F3")),
|
||||
uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")),
|
||||
uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")),
|
||||
uic!("timeline.workAreaBegin", "Set Work Area Begin", [], Some("B")),
|
||||
uic!("timeline.workAreaEnd", "Set Work Area End", [], Some("N")),
|
||||
|
|
@ -60,22 +54,12 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("timeline.reveal.rotation", "Reveal Rotation", [], Some("R")),
|
||||
uic!("timeline.reveal.opacity", "Reveal Opacity", [], Some("T")),
|
||||
uic!("timeline.reveal.anchor", "Reveal Anchor Point", [], Some("A")),
|
||||
uic!("timeline.reveal.animated", "Reveal Animated Properties", ["Animation"], Some("U")),
|
||||
uic!("timeline.reveal.effects", "Reveal Effects", [], Some("E")),
|
||||
uic!("timeline.reveal.masks", "Reveal Masks", [], Some("M")),
|
||||
uic!("timeline.reveal.feather", "Reveal Mask Feather", [], Some("F")),
|
||||
uic!("timeline.reveal.levels", "Reveal Audio Levels (press twice: Waveform)", [], Some("L")),
|
||||
uic!("timeline.reveal.waveform", "Reveal Audio Waveform", [], None),
|
||||
uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")),
|
||||
uic!("window.workspace.default", "Default", ["Window", "Workspace"], Some("Shift+F10")),
|
||||
uic!("window.workspace.standard", "Standard", ["Window", "Workspace"], Some("Shift+F11")),
|
||||
uic!("window.workspace.smallscreen", "Small Screen", ["Window", "Workspace"], Some("Shift+F12")),
|
||||
uic!("window.workspace.animation", "Animation", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.effects", "Effects", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.motiontracking", "Motion Tracking", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.paint", "Paint", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.text", "Text", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.minimal", "Minimal", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.allpanels", "All Panels", ["Window", "Workspace"], None),
|
||||
uic!("window.workspace.reset", "Reset to Saved Layout", ["Window", "Workspace"], None),
|
||||
uic!("tool.selection", "Selection Tool", [], Some("V")),
|
||||
uic!("tool.hand", "Hand Tool", [], Some("H")),
|
||||
uic!("tool.zoom", "Zoom Tool", [], Some("Z")),
|
||||
|
|
@ -90,19 +74,23 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("tool.brush", "Brush Tools", [], Some("Cmd+B")),
|
||||
uic!("tool.rotoBrush", "Roto Brush Tool", [], Some("Alt+W")),
|
||||
uic!("tool.puppet", "Puppet Tools", [], Some("Cmd+P")),
|
||||
uic!("app.newComp", "New Composition…", [], None),
|
||||
uic!("app.compSettings", "Composition Settings…", [], None),
|
||||
uic!("app.solidSettings", "New Solid…", [], None),
|
||||
uic!("app.commandPalette", "Command Palette…", ["Window"], Some("Cmd+Shift+P")),
|
||||
uic!("app.about", "About EffectCraft", ["Help"], None),
|
||||
uic!("app.home", "Home", ["Window"], None),
|
||||
uic!("app.newComp", "New Composition...", [], None),
|
||||
uic!("app.compSettings", "Composition Settings...", [], None),
|
||||
uic!("app.solidSettings", "New Solid...", [], None),
|
||||
uic!("app.home", "Home", [], None),
|
||||
];
|
||||
|
||||
pub fn panel_command_id(p: PanelKind) -> String {
|
||||
format!("window.panel.{}", p.id())
|
||||
}
|
||||
|
||||
fn reveal(app: &mut EffectcraftApp, kind: &str) {
|
||||
fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64) {
|
||||
// AE's double-press shortcuts: L then L again quickly reveals the Waveform (LL).
|
||||
let kind = match app.last_reveal.take() {
|
||||
Some((k, t)) if k == "levels" && kind == "levels" && now - t < 0.6 => "waveform",
|
||||
_ => kind,
|
||||
};
|
||||
app.last_reveal = Some((kind.to_string(), now));
|
||||
let add = app.ui.timeline.reveal.contains(&kind.to_string());
|
||||
if add && app.ui.timeline.reveal.len() == 1 {
|
||||
app.ui.timeline.reveal.clear();
|
||||
|
|
@ -118,6 +106,10 @@ fn reveal(app: &mut EffectcraftApp, kind: &str) {
|
|||
app.ui.timeline.open_layers = sel.iter().map(|l| l.0).collect();
|
||||
}
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
p.as_object().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
let now = ctx.input(|i| i.time);
|
||||
|
|
@ -125,20 +117,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
if crate::panels::dialogs_3d::route(app, id, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Legacy per-panel / per-workspace ids (`window.panel.Project`, `window.workspace.default`).
|
||||
if let Some(rest) = id.strip_prefix("window.panel.") {
|
||||
let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?;
|
||||
app.show_panel(p);
|
||||
return Ok(Value::Null);
|
||||
return frontend(app, ctx, "window.panel", json!({"panel": rest}));
|
||||
}
|
||||
if let Some(ws) = id.strip_prefix("window.workspace.") {
|
||||
if ws == "reset" {
|
||||
let name = app.ui.workspace.clone();
|
||||
app.set_workspace(&name);
|
||||
return Ok(Value::Null);
|
||||
return frontend(app, ctx, "window.resetWorkspace", json!({}));
|
||||
}
|
||||
let name = crate::dock::WORKSPACES.iter().find(|w| w.to_ascii_lowercase().replace(' ', "") == ws).ok_or_else(|| format!("unknown workspace `{ws}`"))?;
|
||||
app.set_workspace(name);
|
||||
return Ok(json!({"workspace": name}));
|
||||
return frontend(app, ctx, "window.workspace", json!({"name": ws}));
|
||||
}
|
||||
if let Some(t) = id.strip_prefix("tool.") {
|
||||
// Slot shortcuts cycle through the slot's tools.
|
||||
|
|
@ -169,11 +156,14 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
return Ok(json!({"tool": tool}));
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.reveal.") {
|
||||
reveal(app, k);
|
||||
if k == "animated" {
|
||||
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
|
||||
}
|
||||
reveal(app, k, now);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(r) = id.strip_prefix("view.res.") {
|
||||
app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?;
|
||||
if id == "view.res.auto" {
|
||||
app.ui.viewer.res = Resolution::Auto;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(th) = id.strip_prefix("view.theme.") {
|
||||
|
|
@ -183,7 +173,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
}
|
||||
let timing = |app: &mut EffectcraftApp, op: &str| app.session.execute("layer.timing", json!({"op": op})).map_err(|e| e.to_string());
|
||||
match id {
|
||||
"playback.toggle" | "playback.ramPreview" => {
|
||||
"playback.ramPreview" => {
|
||||
app.toggle_play(now);
|
||||
return Ok(json!({"playing": app.playback.playing}));
|
||||
}
|
||||
|
|
@ -191,17 +181,6 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
app.stop();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let cur = app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx));
|
||||
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
|
||||
let next = if id == "view.zoomIn" {
|
||||
STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0)
|
||||
} else {
|
||||
STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015)
|
||||
};
|
||||
app.ui.viewer.zoom = Some(next);
|
||||
return Ok(json!({"zoom": next}));
|
||||
}
|
||||
"view.fit" => {
|
||||
app.ui.viewer.zoom = None;
|
||||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
|
|
@ -212,11 +191,8 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
app.ui.viewer.pan = [0.0, 0.0];
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
"view.rulers" => app.ui.viewer.rulers = !app.ui.viewer.rulers,
|
||||
"view.grid" => app.ui.viewer.grid = !app.ui.viewer.grid,
|
||||
"view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins,
|
||||
"view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid,
|
||||
"view.layerControls" => app.ui.viewer.show_layer_controls = !app.ui.viewer.show_layer_controls,
|
||||
"view.fastPreviews" => app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview,
|
||||
"timeline.zoomIn" | "timeline.zoomOut" => {
|
||||
let k = if id == "timeline.zoomIn" { 1.5 } else { 1.0 / 1.5 };
|
||||
|
|
@ -237,31 +213,38 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
"timeline.moveOutToTime" => return timing(app, "moveOutToTime"),
|
||||
"timeline.trimInToTime" => return timing(app, "trimInToTime"),
|
||||
"timeline.trimOutToTime" => return timing(app, "trimOutToTime"),
|
||||
"app.about" => app.dialog = Some(crate::Dialog::About),
|
||||
"app.home" => app.ui.start_screen = !app.ui.start_screen,
|
||||
"app.commandPalette" => {
|
||||
app.dialog_state.palette_query.clear();
|
||||
app.dialog = Some(crate::Dialog::CommandPalette);
|
||||
}
|
||||
"app.newComp" | "comp.new" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
crate::panels::dialogs::open_new_comp(app);
|
||||
}
|
||||
"app.compSettings" | "comp.settings" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
crate::panels::dialogs::open_comp_settings(app)?;
|
||||
}
|
||||
"keys.velocity" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_velocity(app)?,
|
||||
"keys.interpolation" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_interpolation(app)?,
|
||||
"layer.timeStretch" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_time_stretch(app)?,
|
||||
"app.solidSettings" | "layer.newSolid" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
crate::panels::dialogs::open_new_solid(app)?;
|
||||
}
|
||||
"app.newComp" | "comp.new" if no_params(¶ms) => crate::panels::dialogs::open_new_comp(app),
|
||||
"app.compSettings" | "comp.settings" if no_params(¶ms) => crate::panels::dialogs::open_comp_settings(app)?,
|
||||
"app.solidSettings" | "layer.newSolid" if no_params(¶ms) => crate::panels::dialogs::open_new_solid(app)?,
|
||||
"keys.velocity" if no_params(¶ms) => crate::panels::key_dialogs::open_velocity(app)?,
|
||||
"keys.interpolation" if no_params(¶ms) => crate::panels::key_dialogs::open_interpolation(app)?,
|
||||
"layer.timeStretch" if no_params(¶ms) => crate::panels::key_dialogs::open_time_stretch(app)?,
|
||||
_ => {
|
||||
if (id == "file.import" && params.get("paths").is_none() && params.get("path").is_none())
|
||||
|| (id == "file.saveAs" && params.get("path").is_none())
|
||||
|| (id == "file.open" && params.get("path").is_none())
|
||||
|| (id == "file.save" && params.get("path").is_none() && app.session.path.is_none())
|
||||
{
|
||||
return file_dialog(app, id);
|
||||
if id == "renderQueue.render" && params.get("wait").is_none() {
|
||||
// The UI renders in the background; the panel shows progress.
|
||||
let mut p = params.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("wait".into(), json!(false));
|
||||
} else {
|
||||
p = json!({"wait": false});
|
||||
}
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
return app.session.execute(id, p).map_err(|e| e.to_string());
|
||||
}
|
||||
if id == "renderQueue.add" {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
match &r {
|
||||
Ok(_) => app.show_panel(PanelKind::RenderQueue),
|
||||
Err(e) => app.ui.status = e.clone(),
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = file_dialog(app, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if crate::panels::dialogs::open_form(app, id, ¶ms) {
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
if id == "renderQueue.render" && params.get("wait").is_none() {
|
||||
// The UI renders in the background; the panel shows progress.
|
||||
|
|
@ -286,44 +269,270 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
crate::panels::timeline::begin_rename(app, ctx);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
return r;
|
||||
return run_engine(app, ctx, id, params);
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn file_dialog(app: &mut EffectcraftApp, id: &str) -> Result<Value, String> {
|
||||
match id {
|
||||
"file.import" => {
|
||||
let f = app.hooks.pick_files.as_ref().ok_or("no file dialog available (pass `paths`)")?;
|
||||
/// Run an engine command and perform the frontend events it emitted right away.
|
||||
fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
let events = app.session.drain_events();
|
||||
for ev in events {
|
||||
match ev {
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
if let Err(e) = frontend(app, ctx, &command, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
other => app.session.events.push(other),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn toggle(slot: &mut bool, p: &Value) -> Value {
|
||||
*slot = p.get("value").and_then(Value::as_bool).unwrap_or(!*slot);
|
||||
json!(*slot)
|
||||
}
|
||||
|
||||
/// Perform a frontend command (from `Event::Frontend`).
|
||||
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let v = &mut app.ui.viewer;
|
||||
Ok(match id {
|
||||
"app.about" => {
|
||||
app.dialog = Some(crate::Dialog::About);
|
||||
Value::Null
|
||||
}
|
||||
"app.settings" => {
|
||||
app.dialog_state.settings_page = p.get("page").and_then(Value::as_str).unwrap_or("general").to_string();
|
||||
app.dialog = Some(crate::Dialog::Settings);
|
||||
Value::Null
|
||||
}
|
||||
"app.hide" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||||
Value::Null
|
||||
}
|
||||
"app.quit" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
Value::Null
|
||||
}
|
||||
"app.commandPalette" => {
|
||||
app.dialog_state.palette_query = p.get("query").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
app.dialog_state.palette_sel = 0;
|
||||
app.dialog = Some(crate::Dialog::CommandPalette);
|
||||
Value::Null
|
||||
}
|
||||
"app.keyboardShortcuts" => {
|
||||
app.dialog = Some(crate::Dialog::Shortcuts);
|
||||
Value::Null
|
||||
}
|
||||
"app.templates" => {
|
||||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("renderSettings");
|
||||
let title = if kind == "outputModule" { "Output Module Templates" } else { "Render Settings Templates" };
|
||||
crate::panels::dialogs::info(app, title, "Render and output templates are managed from the Render Queue panel (Window ▸ Render Queue).");
|
||||
Value::Null
|
||||
}
|
||||
"app.find" => {
|
||||
if let Some(q) = p.get("query").and_then(Value::as_str) {
|
||||
app.ui.project_search = q.to_string();
|
||||
}
|
||||
app.show_panel(PanelKind::Project);
|
||||
Value::Null
|
||||
}
|
||||
"playback.toggle" => {
|
||||
app.toggle_play(now);
|
||||
json!({"playing": app.playback.playing})
|
||||
}
|
||||
"playback.cacheWhenIdle" => toggle(&mut app.ui.cache_when_idle, &p),
|
||||
"playback.audio" => {
|
||||
let r = toggle(&mut app.ui.preview_audio, &p);
|
||||
if !app.ui.preview_audio {
|
||||
app.audio = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" => {
|
||||
let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx));
|
||||
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
|
||||
let next = if id == "view.zoomIn" {
|
||||
STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0)
|
||||
} else {
|
||||
STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015)
|
||||
};
|
||||
v.zoom = Some(next);
|
||||
json!({"zoom": next})
|
||||
}
|
||||
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => {
|
||||
let r = id.trim_start_matches("view.res.");
|
||||
v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?;
|
||||
Value::Null
|
||||
}
|
||||
"view.rulers" => toggle(&mut v.rulers, &p),
|
||||
"view.guides" => toggle(&mut v.guides, &p),
|
||||
"view.snapToGuides" => toggle(&mut v.snap_guides, &p),
|
||||
"view.lockGuides" => toggle(&mut v.lock_guides, &p),
|
||||
"view.grid" => toggle(&mut v.grid, &p),
|
||||
"view.snapToGrid" => toggle(&mut v.snap_grid, &p),
|
||||
"view.layerControls" => toggle(&mut v.show_layer_controls, &p),
|
||||
"view.options" => {
|
||||
app.dialog = Some(crate::Dialog::ViewOptions);
|
||||
Value::Null
|
||||
}
|
||||
"view.panelBackground" => {
|
||||
if p.get("pick").and_then(Value::as_bool) == Some(true) {
|
||||
let [r, g, b] = v.custom_pasteboard;
|
||||
crate::panels::dialogs::form(
|
||||
app,
|
||||
"Panel Background Color",
|
||||
"view.panelBackground",
|
||||
json!({}),
|
||||
vec![crate::panels::dialogs::Field::text("color", "Color (#rrggbb)", &format!("#{r:02x}{g:02x}{b:02x}"))],
|
||||
);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let c = p.get("color").and_then(Value::as_str).unwrap_or("mediumGray");
|
||||
v.pasteboard = match c {
|
||||
"black" => Some([0, 0, 0]),
|
||||
"darkGray" => Some([0x2a, 0x2a, 0x2a]),
|
||||
"mediumGray" => None,
|
||||
"lightGray" => Some([0xb4, 0xb4, 0xb4]),
|
||||
"white" => Some([0xff, 0xff, 0xff]),
|
||||
"custom" => Some(v.custom_pasteboard),
|
||||
hex => {
|
||||
let c = effectcraft_engine::color::Rgba::from_hex(hex).ok_or_else(|| format!("unknown colour `{hex}`"))?;
|
||||
let rgb = [(c.r * 255.0).round() as u8, (c.g * 255.0).round() as u8, (c.b * 255.0).round() as u8];
|
||||
v.custom_pasteboard = rgb;
|
||||
Some(rgb)
|
||||
}
|
||||
};
|
||||
Value::Null
|
||||
}
|
||||
"view.fullScreen" => {
|
||||
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full));
|
||||
json!(!full)
|
||||
}
|
||||
"window.panel" => {
|
||||
let name = p.get("panel").and_then(Value::as_str).unwrap_or_default();
|
||||
if name.eq_ignore_ascii_case("tools") {
|
||||
app.ui.status = "The Tools panel is the toolbar in the header".into();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let panel = PanelKind::from_name(name).ok_or_else(|| format!("unknown panel `{name}`"))?;
|
||||
app.show_panel(panel);
|
||||
Value::Null
|
||||
}
|
||||
"window.workspace" => {
|
||||
let want = p.get("name").and_then(Value::as_str).unwrap_or("Default").to_ascii_lowercase().replace(' ', "");
|
||||
let name = crate::dock::WORKSPACES
|
||||
.iter()
|
||||
.find(|w| w.to_ascii_lowercase().replace(' ', "") == want)
|
||||
.ok_or_else(|| format!("unknown workspace `{want}`"))?;
|
||||
app.set_workspace(name);
|
||||
json!({"workspace": name})
|
||||
}
|
||||
"window.resetWorkspace" => {
|
||||
let name = app.ui.workspace.clone();
|
||||
app.set_workspace(&name);
|
||||
Value::Null
|
||||
}
|
||||
"comp.flowchart" | "comp.miniFlowchart" => {
|
||||
app.show_panel(PanelKind::Flowchart);
|
||||
Value::Null
|
||||
}
|
||||
"layer.openLayer" => {
|
||||
app.show_panel(PanelKind::Layer);
|
||||
Value::Null
|
||||
}
|
||||
"effect.manage" | "anim.browsePresets" => {
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
Value::Null
|
||||
}
|
||||
"timeline.revealProps" => {
|
||||
let props = p.get("props").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let tl = &mut app.ui.timeline;
|
||||
tl.reveal_props = props.iter().filter_map(|x| x.get("prop").and_then(Value::as_u64)).collect();
|
||||
tl.open_layers = props.iter().filter_map(|x| x.get("layer").and_then(Value::as_u64)).collect();
|
||||
tl.reveal = if tl.reveal_props.is_empty() { vec![] } else { vec!["props".into()] };
|
||||
json!({"revealed": tl.reveal_props.len()})
|
||||
}
|
||||
_ => return Err(format!("`{id}` is not a frontend command")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Commands that need a file or folder path: ask the host's file dialog when `params` lacks one.
|
||||
fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
enum Ask {
|
||||
Import,
|
||||
OpenProject,
|
||||
Save(&'static str),
|
||||
Open(&'static [&'static str]),
|
||||
}
|
||||
let (key, ask) = match id {
|
||||
"file.import" | "file.importMultiple" => ("paths", Ask::Import),
|
||||
"file.open" => ("path", Ask::OpenProject),
|
||||
"file.save" if app.session.path.is_none() => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"comp.saveFrameAs" => ("path", Ask::Save("Frame.png")),
|
||||
"anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")),
|
||||
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
||||
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
_ => return None,
|
||||
};
|
||||
if params.get(key).is_some()
|
||||
|| params.get("path").is_some()
|
||||
|| params.get("paths").is_some()
|
||||
|| params.get("steps").is_some()
|
||||
|| params.get("preset").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
let picked: Option<Value> = match ask {
|
||||
Ask::Import => {
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
let paths = f(&[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
|
||||
"ogg", "opus", "svg",
|
||||
]);
|
||||
if paths.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
app.session.execute("file.import", json!({"paths": paths})).map_err(|e| e.to_string())
|
||||
}
|
||||
"file.open" => {
|
||||
let f = app.hooks.pick_open_project.as_ref().ok_or("no file dialog available (pass `path`)")?;
|
||||
match f() {
|
||||
Some(p) => app.session.execute("file.open", json!({"path": p})).map_err(|e| e.to_string()),
|
||||
None => Ok(Value::Null),
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
(false, "paths") => Some(json!(paths)),
|
||||
(false, _) => paths.first().map(|s| json!(s)),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let f = app.hooks.pick_save.as_ref().ok_or("no file dialog available (pass `path`)")?;
|
||||
match f("Untitled Project.ecproj") {
|
||||
Some(p) => app.session.execute("file.saveAs", json!({"path": p})).map_err(|e| e.to_string()),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
Ask::OpenProject => {
|
||||
let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
f().map(|s| json!(s))
|
||||
}
|
||||
Ask::Open(exts) => {
|
||||
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::Save(default) => {
|
||||
let Some(f) = app.hooks.pick_save.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
f(default).map(|s| json!(s))
|
||||
}
|
||||
};
|
||||
let Some(v) = picked else { return Some(Ok(Value::Null)) };
|
||||
p.insert(key.to_string(), v);
|
||||
// Save (untitled) becomes Save As.
|
||||
let id = if id == "file.save" { "file.saveAs" } else { id };
|
||||
let r = app.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// A menu entry for display / `ui.menu.list`.
|
||||
|
|
@ -334,104 +543,90 @@ pub struct MenuItem {
|
|||
pub path: Vec<String>,
|
||||
pub shortcut: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checked: Option<bool>,
|
||||
/// Parameters bound to the entry (e.g. the effect for Effect menu items).
|
||||
#[serde(skip_serializing_if = "Value::is_null")]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
pub const MENUS: [&str; 9] = ["File", "Edit", "Composition", "Layer", "Effect", "Animation", "View", "Window", "Help"];
|
||||
/// Top-level menus in order.
|
||||
pub fn menus() -> Vec<&'static str> {
|
||||
effectcraft_engine::menus::top_level()
|
||||
}
|
||||
|
||||
fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String {
|
||||
match e.command.as_str() {
|
||||
"edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()),
|
||||
"edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()),
|
||||
_ => e.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check-mark state of frontend toggles (engine state is answered by the engine).
|
||||
fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool> {
|
||||
let v = &app.ui.viewer;
|
||||
let pstr = |k: &str| e.params.get(k).and_then(Value::as_str);
|
||||
match e.command.as_str() {
|
||||
"view.rulers" => Some(v.rulers),
|
||||
"view.guides" => Some(v.guides),
|
||||
"view.snapToGuides" => Some(v.snap_guides),
|
||||
"view.lockGuides" => Some(v.lock_guides),
|
||||
"view.grid" => Some(v.grid),
|
||||
"view.snapToGrid" => Some(v.snap_grid),
|
||||
"view.layerControls" => Some(v.show_layer_controls),
|
||||
"playback.cacheWhenIdle" => Some(app.ui.cache_when_idle),
|
||||
"playback.audio" => Some(app.ui.preview_audio),
|
||||
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => {
|
||||
Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res.")))
|
||||
}
|
||||
"window.workspace" => Some(pstr("name").is_some_and(|n| n == app.ui.workspace)),
|
||||
"view.panelBackground" if e.params.get("pick").is_none() => {
|
||||
let cur = match v.pasteboard {
|
||||
None => "mediumGray",
|
||||
Some([0, 0, 0]) => "black",
|
||||
Some([0x2a, 0x2a, 0x2a]) => "darkGray",
|
||||
Some([0xb4, 0xb4, 0xb4]) => "lightGray",
|
||||
Some([0xff, 0xff, 0xff]) => "white",
|
||||
Some(_) => "custom",
|
||||
};
|
||||
Some(pstr("color") == Some(cur))
|
||||
}
|
||||
_ => effectcraft_engine::menus::checked(&app.session, &e.command, &e.params),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
|
||||
if e.command == "window.panel" {
|
||||
return true;
|
||||
}
|
||||
app.session.is_enabled(&e.command)
|
||||
}
|
||||
|
||||
/// Every menu entry, flattened with its submenu path.
|
||||
pub fn menu_items(app: &EffectcraftApp) -> Vec<MenuItem> {
|
||||
let mut out = Vec::new();
|
||||
for c in effectcraft_engine::command_specs() {
|
||||
if c.menu.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(MenuItem {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
path: c.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: c.shortcut.map(str::to_string),
|
||||
enabled: app.session.is_enabled(c.id),
|
||||
params: Value::Null,
|
||||
});
|
||||
}
|
||||
for c in UI_COMMANDS {
|
||||
if c.menu.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(MenuItem {
|
||||
id: c.id.into(),
|
||||
label: c.label.into(),
|
||||
path: c.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: c.shortcut.map(str::to_string),
|
||||
enabled: true,
|
||||
params: Value::Null,
|
||||
});
|
||||
}
|
||||
for p in PanelKind::ALL {
|
||||
out.push(MenuItem {
|
||||
id: panel_command_id(p),
|
||||
label: p.title().into(),
|
||||
path: vec!["Window".into()],
|
||||
shortcut: p.window_shortcut().map(str::to_string),
|
||||
enabled: true,
|
||||
params: Value::Null,
|
||||
});
|
||||
}
|
||||
// Animation ▸ Animate Text / Add Text Selector / Text Animation Presets.
|
||||
let anim_on = app.session.is_enabled("layer.addTextAnimator");
|
||||
for (k, l) in effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-") {
|
||||
out.push(MenuItem {
|
||||
id: "layer.addTextAnimator".into(),
|
||||
label: (*l).into(),
|
||||
path: vec!["Animation".into(), "Animate Text".into()],
|
||||
shortcut: None,
|
||||
enabled: anim_on,
|
||||
params: json!({"property": k}),
|
||||
});
|
||||
}
|
||||
let sel_on = app.session.is_enabled("layer.addTextSelector");
|
||||
for (k, l) in [("range", "Range"), ("wiggly", "Wiggly"), ("expression", "Expression")] {
|
||||
out.push(MenuItem {
|
||||
id: "layer.addTextSelector".into(),
|
||||
label: l.into(),
|
||||
path: vec!["Animation".into(), "Add Text Selector".into()],
|
||||
shortcut: None,
|
||||
enabled: sel_on,
|
||||
params: json!({"kind": k}),
|
||||
});
|
||||
}
|
||||
let preset_on = app.session.is_enabled("layer.applyTextPreset");
|
||||
for (id, name) in effectcraft_engine::text_presets() {
|
||||
out.push(MenuItem {
|
||||
id: "layer.applyTextPreset".into(),
|
||||
label: name,
|
||||
path: vec!["Animation".into(), "Text Animation Presets".into()],
|
||||
shortcut: None,
|
||||
enabled: preset_on,
|
||||
params: json!({"preset": id}),
|
||||
});
|
||||
}
|
||||
let has_layer = !app.session.state.selected_layers.is_empty();
|
||||
for e in effectcraft_engine::effects::registry() {
|
||||
out.push(MenuItem {
|
||||
id: "effect.apply".into(),
|
||||
label: e.name.into(),
|
||||
path: vec!["Effect".into(), e.category.into()],
|
||||
shortcut: None,
|
||||
enabled: has_layer,
|
||||
params: json!({"effect": e.id}),
|
||||
});
|
||||
}
|
||||
out
|
||||
effectcraft_engine::menus::entries()
|
||||
.into_iter()
|
||||
.map(|(path, e)| MenuItem {
|
||||
id: e.command.clone(),
|
||||
label: entry_label(app, e),
|
||||
path,
|
||||
shortcut: e.shortcut.clone(),
|
||||
enabled: entry_enabled(app, e),
|
||||
checked: entry_checked(app, e),
|
||||
params: e.params.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere).
|
||||
pub fn shortcut_text(s: &str) -> String {
|
||||
if cfg!(target_os = "macos") {
|
||||
let mut out = String::new();
|
||||
let parts: Vec<&str> = s.split('+').collect();
|
||||
let parts: Vec<&str> = match s.strip_suffix("++") {
|
||||
Some(head) => vec![head, "+"],
|
||||
None => s.split('+').collect(),
|
||||
};
|
||||
let (mods, key) = parts.split_at(parts.len().saturating_sub(1));
|
||||
for m in mods {
|
||||
out.push_str(match *m {
|
||||
|
|
@ -442,7 +637,12 @@ pub fn shortcut_text(s: &str) -> String {
|
|||
x => x,
|
||||
});
|
||||
}
|
||||
out.push_str(key.first().copied().unwrap_or(""));
|
||||
out.push_str(match key.first().copied().unwrap_or("") {
|
||||
"Space" => "Space",
|
||||
"Escape" => "Esc",
|
||||
"Home" => "↖",
|
||||
k => k,
|
||||
});
|
||||
out
|
||||
} else {
|
||||
s.replace("Cmd", "Ctrl")
|
||||
|
|
@ -489,22 +689,53 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> {
|
|||
key.map(|k| (m, k))
|
||||
}
|
||||
|
||||
/// The key some keyboards report for a shifted punctuation key (`Shift+=` arrives as `+`).
|
||||
fn shifted_alias(k: egui::Key) -> Option<egui::Key> {
|
||||
use egui::Key::*;
|
||||
Some(match k {
|
||||
Equals => Plus,
|
||||
Semicolon => Colon,
|
||||
OpenBracket => OpenCurlyBracket,
|
||||
CloseBracket => CloseCurlyBracket,
|
||||
Slash => Questionmark,
|
||||
Backslash => Pipe,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A key binding: modifiers, key, command id and bound params.
|
||||
pub type Binding = (egui::Modifiers, egui::Key, String, Value);
|
||||
|
||||
/// All active bindings, most modifiers first.
|
||||
pub fn bindings() -> Vec<(egui::Modifiers, egui::Key, String)> {
|
||||
let mut v: Vec<(egui::Modifiers, egui::Key, String)> = Vec::new();
|
||||
pub fn bindings() -> Vec<Binding> {
|
||||
let mut v: Vec<Binding> = Vec::new();
|
||||
let mut push = |sc: &str, id: &str, params: Value| {
|
||||
if let Some((m, k)) = parse_shortcut(sc) {
|
||||
if m.shift
|
||||
&& let Some(alias) = shifted_alias(k)
|
||||
{
|
||||
v.push((m, alias, id.to_string(), params.clone()));
|
||||
}
|
||||
v.push((m, k, id.to_string(), params));
|
||||
}
|
||||
};
|
||||
// Menu entries (their shortcuts may bind params), then command defaults not in the menus.
|
||||
let entries = effectcraft_engine::menus::entries();
|
||||
for (_, e) in &entries {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
push(sc, &e.command, e.params.clone());
|
||||
}
|
||||
}
|
||||
for c in effectcraft_engine::command_specs() {
|
||||
if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) {
|
||||
v.push((m, k, c.id.to_string()));
|
||||
if let Some(sc) = c.shortcut
|
||||
&& !entries.iter().any(|(_, e)| e.command == c.id)
|
||||
{
|
||||
push(sc, c.id, Value::Null);
|
||||
}
|
||||
}
|
||||
for c in UI_COMMANDS {
|
||||
if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) {
|
||||
v.push((m, k, c.id.to_string()));
|
||||
}
|
||||
}
|
||||
for p in PanelKind::ALL {
|
||||
if let Some((m, k)) = p.window_shortcut().and_then(parse_shortcut) {
|
||||
v.push((m, k, panel_command_id(p)));
|
||||
if let Some(sc) = c.shortcut {
|
||||
push(sc, c.id, Value::Null);
|
||||
}
|
||||
}
|
||||
v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8));
|
||||
|
|
@ -551,57 +782,30 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
let _ = invoke(app, ctx, "edit.clear", json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some((_, _, id)) = binds.iter().find(|(m, k, _)| *k == key && mods_match(*m, mods)) {
|
||||
let id = id.clone();
|
||||
if let Err(e) = invoke(app, ctx, &id, json!({})) {
|
||||
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
|
||||
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
|
||||
if let Err(e) = invoke(app, ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the in-window menu bar.
|
||||
/// Draw the in-window menu bar (the engine's AE menu tree).
|
||||
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let items = menu_items(app);
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add_space(6.0);
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
for top in MENUS {
|
||||
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
ui.menu_button(top, |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
let mut subs: Vec<&str> = Vec::new();
|
||||
let mut last_was_sub = false;
|
||||
for it in &mine {
|
||||
if it.path.len() > 1 {
|
||||
let sub = it.path[1].as_str();
|
||||
if !subs.contains(&sub) {
|
||||
subs.push(sub);
|
||||
ui.menu_button(sub, |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
for s in mine.iter().filter(|x| x.path.get(1).map(String::as_str) == Some(sub)) {
|
||||
if menu_entry(ui, s) {
|
||||
clicked = Some((s.id.clone(), s.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
last_was_sub = true;
|
||||
}
|
||||
} else {
|
||||
if last_was_sub && top != "Effect" {
|
||||
ui.separator();
|
||||
last_was_sub = false;
|
||||
}
|
||||
if menu_entry(ui, it) {
|
||||
clicked = Some((it.id.clone(), it.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for node in effectcraft_engine::menus::menu_bar() {
|
||||
if let MenuNode::Submenu { label, children } = node {
|
||||
let r = ui.menu_button(label, |ui| {
|
||||
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
|
||||
menu_nodes(app, ui, children, &mut clicked);
|
||||
});
|
||||
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -612,10 +816,81 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool {
|
||||
let mut b = egui::Button::new(&it.label);
|
||||
if let Some(s) = &it.shortcut {
|
||||
fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Separator => {
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button((gutter(false), label.as_str()), |ui| {
|
||||
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
|
||||
// Long submenus (Blending Mode, effect categories) scroll instead of running
|
||||
// off the screen.
|
||||
let max_h = ui.ctx().content_rect().height() - 40.0;
|
||||
egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| menu_nodes(app, ui, children, clicked));
|
||||
});
|
||||
}
|
||||
MenuNode::Item(e) => {
|
||||
if menu_entry(app, ui, e) {
|
||||
*clicked = Some((e.command.clone(), e.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The check-mark column every menu row reserves (like macOS / After Effects menus).
|
||||
fn gutter(checked: bool) -> egui::Atom<'static> {
|
||||
use egui::AtomExt;
|
||||
(if checked { "✔" } else { "" }).atom_size(egui::vec2(14.0, 14.0))
|
||||
}
|
||||
|
||||
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
|
||||
let label = entry_label(app, e);
|
||||
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
|
||||
if let Some(s) = &e.shortcut {
|
||||
b = b.shortcut_text(shortcut_text(s));
|
||||
}
|
||||
ui.add_enabled(it.enabled, b).clicked()
|
||||
ui.add_enabled(entry_enabled(app, e), b).clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_ui_shortcut_parses() {
|
||||
for c in UI_COMMANDS {
|
||||
if let Some(sc) = c.shortcut {
|
||||
assert!(parse_shortcut(sc).is_some() || sc == "Num*", "{sc}");
|
||||
}
|
||||
}
|
||||
for (_, e) in effectcraft_engine::menus::entries() {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
assert!(parse_shortcut(sc).is_some() || sc == "Num*", "{} ({sc})", e.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_and_menu_shortcuts_do_not_collide() {
|
||||
let mut seen: std::collections::BTreeMap<(String, String), (String, String)> = Default::default();
|
||||
for (m, k, id, params) in bindings() {
|
||||
let key = (format!("{m:?}"), format!("{k:?}"));
|
||||
let target = (id.clone(), params.to_string());
|
||||
if let Some(prev) = seen.get(&key) {
|
||||
assert_eq!(prev, &target, "{key:?} bound to {prev:?} and {target:?}");
|
||||
} else {
|
||||
seen.insert(key, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_items_cover_the_tree() {
|
||||
assert_eq!(menus().last(), Some(&"Help"));
|
||||
assert!(effectcraft_engine::menus::entries().len() > 500);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
pub use super::forms::{Field, form, info, open_form};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
|
@ -19,6 +20,12 @@ pub struct DialogState {
|
|||
pub solid_size: [u32; 2],
|
||||
pub palette_query: String,
|
||||
pub palette_sel: usize,
|
||||
/// Settings dialog page id (`general`, `appearance`…).
|
||||
pub settings_page: String,
|
||||
/// The open parameter form.
|
||||
pub form: super::forms::Form,
|
||||
/// Message box (title, body).
|
||||
pub info: (String, String),
|
||||
pub camera: super::dialogs_3d::CameraDraft,
|
||||
pub light: super::dialogs_3d::LightDraft,
|
||||
pub velocity: super::key_dialogs::VelocityDraft,
|
||||
|
|
@ -91,6 +98,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
|
||||
Dialog::SolidSettings => solid(app, ctx, &t),
|
||||
Dialog::CommandPalette => palette(app, ctx, &t),
|
||||
Dialog::Settings => super::forms::show_settings(app, ctx, &t),
|
||||
Dialog::Shortcuts => super::forms::show_shortcuts(app, ctx, &t),
|
||||
Dialog::Form => super::forms::show_form(app, ctx, &t),
|
||||
Dialog::Info => super::forms::show_info(app, ctx, &t),
|
||||
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
|
||||
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
|
||||
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
|
||||
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
//! Menu dialogs: a generic parameter form (numeric Transform dialogs, Mask Feather/Opacity/
|
||||
//! Expansion, Auto-Orient, Go to Time, Add Guide, Sequence Layers, Interpret Footage, Project
|
||||
//! Settings, placeholders…), Settings (Preferences), Keyboard Shortcuts, View Options and simple
|
||||
//! message boxes. Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! dialog does an agent can do with one `engine.execute`.
|
||||
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FieldKind {
|
||||
Number {
|
||||
value: f64,
|
||||
speed: f64,
|
||||
},
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
/// Options as (label, value); `sel` is the chosen index.
|
||||
Choice {
|
||||
options: Vec<(String, Value)>,
|
||||
sel: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// One form field. `key` may index into an array param (`value[1]`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Field {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub kind: FieldKind,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn num(key: &str, label: &str, value: f64) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Number { value, speed: 1.0 } }
|
||||
}
|
||||
pub fn text(key: &str, label: &str, value: &str) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Text(value.into()) }
|
||||
}
|
||||
pub fn bool(key: &str, label: &str, value: bool) -> Field {
|
||||
Field { key: key.into(), label: label.into(), kind: FieldKind::Bool(value) }
|
||||
}
|
||||
pub fn choice(key: &str, label: &str, options: &[(&str, Value)], sel: usize) -> Field {
|
||||
Field {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
kind: FieldKind::Choice { options: options.iter().map(|(l, v)| (l.to_string(), v.clone())).collect(), sel },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Form {
|
||||
pub title: String,
|
||||
pub command: String,
|
||||
pub base: Value,
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
/// The command parameters: `base` plus every field.
|
||||
pub fn params(&self) -> Value {
|
||||
let mut m: Map<String, Value> = self.base.as_object().cloned().unwrap_or_default();
|
||||
for f in &self.fields {
|
||||
let v = match &f.kind {
|
||||
FieldKind::Number { value, .. } => json!(value),
|
||||
FieldKind::Text(s) => json!(s),
|
||||
FieldKind::Bool(b) => json!(b),
|
||||
FieldKind::Choice { options, sel } => options.get(*sel).map(|o| o.1.clone()).unwrap_or(Value::Null),
|
||||
};
|
||||
match f.key.split_once('[') {
|
||||
Some((name, idx)) => {
|
||||
let i: usize = idx.trim_end_matches(']').parse().unwrap_or(0);
|
||||
let arr = m.entry(name.to_string()).or_insert_with(|| json!([]));
|
||||
if let Value::Array(a) = arr {
|
||||
while a.len() <= i {
|
||||
a.push(json!(0));
|
||||
}
|
||||
a[i] = v;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
m.insert(f.key.clone(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a form dialog.
|
||||
pub fn form(app: &mut EffectcraftApp, title: &str, command: &str, base: Value, fields: Vec<Field>) {
|
||||
app.dialog_state.form = Form { title: title.into(), command: command.into(), base, fields };
|
||||
app.dialog = Some(Dialog::Form);
|
||||
}
|
||||
|
||||
/// Open a message box.
|
||||
pub fn info(app: &mut EffectcraftApp, title: &str, body: &str) {
|
||||
app.dialog_state.info = (title.into(), body.into());
|
||||
app.dialog = Some(Dialog::Info);
|
||||
}
|
||||
|
||||
fn has(p: &Value, keys: &[&str]) -> bool {
|
||||
keys.iter().any(|k| p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
|
||||
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let s = &app.session;
|
||||
let comp = s.active_comp();
|
||||
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 (title, fields): (String, Vec<Field>) = match id {
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
|
||||
let three = layer.is_some_and(|l| l.is_3d());
|
||||
let title = match prop {
|
||||
"anchor" => "Anchor Point",
|
||||
"position" => "Position",
|
||||
"scale" => "Scale",
|
||||
"orientation" => "Orientation",
|
||||
"rotation" => "Rotation",
|
||||
_ => "Opacity",
|
||||
};
|
||||
let fields = match prop {
|
||||
"rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
|
||||
"orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
};
|
||||
(title.into(), fields)
|
||||
}
|
||||
"layer.mask.set" if !has(p, &["value"]) => {
|
||||
let field = p.get("field").and_then(Value::as_str).unwrap_or("feather");
|
||||
let first = layer.and_then(|l| l.masks()).and_then(|m| m.groups().next()).and_then(|g| g.get(field)).map(|pr| pr.value_at(lt).as_f64());
|
||||
let (title, label, d) = match field {
|
||||
"feather" => ("Mask Feather", "Feather (pixels)", 0.0),
|
||||
"opacity" => ("Mask Opacity", "Opacity (%)", 100.0),
|
||||
_ => ("Mask Expansion", "Expansion (pixels)", 0.0),
|
||||
};
|
||||
(title.into(), vec![Field::num("value", label, first.unwrap_or(d))])
|
||||
}
|
||||
"layer.mask.shape" if !has(p, &["rect"]) => {
|
||||
let (w, h) = layer.map(|l| effectcraft_engine::render::source_size(&s.project, l)).unwrap_or((100, 100));
|
||||
(
|
||||
"Mask Shape".into(),
|
||||
vec![
|
||||
Field::num("rect[0]", "Left", 0.0),
|
||||
Field::num("rect[1]", "Top", 0.0),
|
||||
Field::num("rect[2]", "Width", w as f64),
|
||||
Field::num("rect[3]", "Height", h as f64),
|
||||
Field::choice("shape", "Shape", &[("Rectangle", json!("rect")), ("Ellipse", json!("ellipse"))], 0),
|
||||
],
|
||||
)
|
||||
}
|
||||
"layer.autoOrient" if !has(p, &["mode"]) => {
|
||||
let cur = layer.map(|l| l.auto_orient as usize).unwrap_or(0);
|
||||
(
|
||||
"Auto-Orientation".into(),
|
||||
vec![Field::choice(
|
||||
"mode",
|
||||
"Mode",
|
||||
&[
|
||||
("Off", json!("off")),
|
||||
("Orient Along Path", json!("alongPath")),
|
||||
("Orient Towards Camera", json!("towardsCamera")),
|
||||
("Orient Towards Point of Interest", json!("towardsPointOfInterest")),
|
||||
],
|
||||
cur.min(3),
|
||||
)],
|
||||
)
|
||||
}
|
||||
"time.set" if !has(p, &["time", "frame", "timecode"]) => {
|
||||
let frame = comp.map(|c| c.frame_rate.frame_at(t)).unwrap_or(0);
|
||||
("Go to Time".into(), vec![Field::num("frame", "Frame", frame as f64)])
|
||||
}
|
||||
"view.addGuide" if !has(p, &["position"]) => {
|
||||
let (w, _) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
|
||||
(
|
||||
"Add Guide".into(),
|
||||
vec![
|
||||
Field::choice("orientation", "Orientation", &[("Vertical", json!("vertical")), ("Horizontal", json!("horizontal"))], 0),
|
||||
Field::num("position", "Position (pixels)", w as f64 / 2.0),
|
||||
],
|
||||
)
|
||||
}
|
||||
"layer.sequence" if !has(p, &["overlap"]) => (
|
||||
"Sequence Layers".into(),
|
||||
vec![
|
||||
Field::bool("overlap", "Overlap", false),
|
||||
Field::num("duration", "Duration (seconds)", 1.0),
|
||||
Field::choice(
|
||||
"transition",
|
||||
"Transition",
|
||||
&[
|
||||
("Off", json!("off")),
|
||||
("Dissolve Front Layer", json!("dissolveFront")),
|
||||
("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
|
||||
],
|
||||
0,
|
||||
),
|
||||
],
|
||||
),
|
||||
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect"]) => {
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind {
|
||||
effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(f) = f else { return false };
|
||||
(
|
||||
"Interpret Footage".into(),
|
||||
vec![
|
||||
Field::num("frameRate", "Frame rate", f.frame_rate.as_f64()),
|
||||
Field::choice(
|
||||
"alpha",
|
||||
"Alpha",
|
||||
&[("Straight", json!("straight")), ("Premultiplied", json!("premultiplied")), ("Ignore", json!("ignore"))],
|
||||
f.alpha as usize,
|
||||
),
|
||||
Field::num("loop", "Loop (times)", f.loop_count as f64),
|
||||
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
|
||||
],
|
||||
)
|
||||
}
|
||||
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
let st = &s.project.settings;
|
||||
let depth = match st.bit_depth.label() {
|
||||
l if l.starts_with("16") => 1,
|
||||
l if l.starts_with("32") => 2,
|
||||
_ => 0,
|
||||
};
|
||||
(
|
||||
"Project Settings".into(),
|
||||
vec![
|
||||
Field::choice(
|
||||
"bitDepth",
|
||||
"Depth",
|
||||
&[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
|
||||
depth,
|
||||
),
|
||||
Field::choice(
|
||||
"timeDisplay",
|
||||
"Time display style",
|
||||
&[("Timecode", json!("timecode")), ("Frames", json!("frames"))],
|
||||
usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)),
|
||||
),
|
||||
Field::bool("linearize", "Linearize working space", st.linearize),
|
||||
],
|
||||
)
|
||||
}
|
||||
"layer.precompose" if !has(p, &["name"]) => {
|
||||
let n = s.project.comps().count() + 1;
|
||||
("Pre-compose".into(), vec![Field::text("name", "New composition name", &format!("Pre-comp {n}"))])
|
||||
}
|
||||
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
|
||||
"New Placeholder".into(),
|
||||
vec![
|
||||
Field::text("name", "Name", "Placeholder"),
|
||||
Field::num("width", "Width", 1920.0),
|
||||
Field::num("height", "Height", 1080.0),
|
||||
Field::num("frameRate", "Frame rate", 29.97),
|
||||
Field::num("duration", "Duration (seconds)", 30.0),
|
||||
],
|
||||
),
|
||||
"file.importSolid" | "file.replaceWithSolid" if p.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
let (w, h) = comp.map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
|
||||
(
|
||||
"Solid Settings".into(),
|
||||
vec![
|
||||
Field::text("name", "Name", "Solid"),
|
||||
Field::text("color", "Color (#rrggbb)", "#808080"),
|
||||
Field::num("width", "Width", w as f64),
|
||||
Field::num("height", "Height", h as f64),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
form(app, &title, id, base, fields);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut f = app.dialog_state.form.clone();
|
||||
let (mut ok, mut close) = (false, false);
|
||||
let h = 120.0 + f.fields.len() as f32 * 30.0;
|
||||
super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| {
|
||||
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
for fl in &mut f.fields {
|
||||
ui.label(&fl.label);
|
||||
match &mut fl.kind {
|
||||
FieldKind::Number { value, speed } => {
|
||||
ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(3));
|
||||
}
|
||||
FieldKind::Text(s) => {
|
||||
ui.add(egui::TextEdit::singleline(s).desired_width(220.0));
|
||||
}
|
||||
FieldKind::Bool(b) => {
|
||||
ui.checkbox(b, "");
|
||||
}
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
egui::ComboBox::from_id_salt(("form", fl.key.as_str())).selected_text(cur).width(240.0).show_ui(ui, |ui| {
|
||||
for (i, (l, _)) in options.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, l);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
ok = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let enter = !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
app.dialog_state.form = f.clone();
|
||||
if ok || enter {
|
||||
app.dialog = None;
|
||||
let params = f.params();
|
||||
if let Err(e) = app.session.execute(&f.command, params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let (title, body) = app.dialog_state.info.clone();
|
||||
let mut close = false;
|
||||
super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| {
|
||||
ui.label(body);
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub const SETTINGS_PAGES: [(&str, &str); 17] = [
|
||||
("general", "General"),
|
||||
("startup", "Startup & Repair"),
|
||||
("project", "Project"),
|
||||
("composition", "Composition"),
|
||||
("previews", "Previews"),
|
||||
("appearance", "Appearance"),
|
||||
("grids", "Grids & Guides"),
|
||||
("labels", "Labels"),
|
||||
("type", "Type"),
|
||||
("import", "Import"),
|
||||
("export", "Export"),
|
||||
("audio", "Audio"),
|
||||
("disk", "Disk"),
|
||||
("memory", "Memory & CPU"),
|
||||
("video", "Video"),
|
||||
("3d", "3D"),
|
||||
("scripting", "Scripting & Expressions"),
|
||||
];
|
||||
|
||||
pub fn show_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut page = app.dialog_state.settings_page.clone();
|
||||
let mut close = false;
|
||||
let mut theme = None;
|
||||
super::dialogs::modal(ctx, "Settings", vec2(640.0, 440.0), t, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(170.0);
|
||||
for (id, label) in SETTINGS_PAGES {
|
||||
if ui.selectable_label(page == id, label).clicked() {
|
||||
page = id.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
let title = SETTINGS_PAGES.iter().find(|p| p.0 == page).map(|p| p.1).unwrap_or("General");
|
||||
ui.label(egui::RichText::new(title).font(Tokens::semibold(13.0)));
|
||||
ui.add_space(8.0);
|
||||
match page.as_str() {
|
||||
"general" => {
|
||||
ui.checkbox(&mut app.ui.show_menu_bar, "Show the in-window menu bar");
|
||||
ui.checkbox(&mut app.ui.snapping, "Snapping");
|
||||
}
|
||||
"appearance" => {
|
||||
for (k, name) in [("dark", "Dark"), ("darker", "Darker"), ("light", "Light")] {
|
||||
if ui.button(name).clicked() {
|
||||
theme = crate::theme::ThemeKind::from_name(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
"previews" => {
|
||||
ui.checkbox(&mut app.ui.cache_when_idle, "Cache frames when idle");
|
||||
ui.checkbox(&mut app.ui.viewer.fast_preview, "Fast previews");
|
||||
}
|
||||
"grids" => {
|
||||
ui.checkbox(&mut app.ui.viewer.grid, "Show grid");
|
||||
ui.checkbox(&mut app.ui.viewer.guides, "Show guides");
|
||||
ui.checkbox(&mut app.ui.viewer.safe_margins, "Show title/action safe");
|
||||
}
|
||||
"labels" => {
|
||||
for l in effectcraft_engine::color::Label::ALL.into_iter().skip(1) {
|
||||
ui.horizontal(|ui| {
|
||||
let [r, g, b] = l.rgb();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(26.0, 14.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(r, g, b));
|
||||
ui.label(l.name());
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new("No options on this page yet.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
if let Some(k) = theme {
|
||||
app.set_theme(ctx, k);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut q = app.dialog_state.palette_query.clone();
|
||||
let mut close = false;
|
||||
// (shortcut, where, label)
|
||||
let mut rows: Vec<(String, String, String)> = vec![];
|
||||
for (path, e) in effectcraft_engine::menus::entries() {
|
||||
if let Some(sc) = &e.shortcut {
|
||||
rows.push((crate::menus::shortcut_text(sc), path.join(" ▸ "), e.label.clone()));
|
||||
}
|
||||
}
|
||||
for c in effectcraft_engine::command_specs().iter().filter(|c| c.menu.is_empty()) {
|
||||
if let Some(sc) = c.shortcut
|
||||
&& !rows.iter().any(|r| r.2 == c.label)
|
||||
{
|
||||
rows.push((crate::menus::shortcut_text(sc), "Commands".into(), c.label.to_string()));
|
||||
}
|
||||
}
|
||||
for c in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = c.shortcut {
|
||||
rows.push((crate::menus::shortcut_text(sc), "Panels & Tools".into(), c.label.to_string()));
|
||||
}
|
||||
}
|
||||
super::dialogs::modal(ctx, "Keyboard Shortcuts", vec2(640.0, 520.0), t, |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or shortcuts").desired_width(f32::INFINITY));
|
||||
ui.add_space(8.0);
|
||||
let ql = q.to_lowercase();
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcut-grid").num_columns(3).striped(true).spacing([18.0, 4.0]).show(ui, |ui| {
|
||||
for (sc, place, label) in rows.iter().filter(|r| ql.is_empty() || format!("{} {} {}", r.0, r.1, r.2).to_lowercase().contains(&ql)) {
|
||||
ui.label(egui::RichText::new(sc).monospace());
|
||||
ui.label(label);
|
||||
ui.label(egui::RichText::new(place).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
app.dialog_state.palette_query = q;
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut close = false;
|
||||
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {
|
||||
let v = &mut app.ui.viewer;
|
||||
ui.checkbox(&mut v.show_layer_controls, "Layer controls");
|
||||
ui.checkbox(&mut v.show_masks, "Masks");
|
||||
ui.checkbox(&mut v.rulers, "Rulers");
|
||||
ui.checkbox(&mut v.guides, "Guides");
|
||||
ui.checkbox(&mut v.grid, "Grid");
|
||||
ui.checkbox(&mut v.safe_margins, "Title/action safe");
|
||||
ui.checkbox(&mut v.transparency_grid, "Transparency grid");
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn form_params_build_arrays_and_choices() {
|
||||
let f = Form {
|
||||
title: "T".into(),
|
||||
command: "layer.setTransform".into(),
|
||||
base: json!({"prop": "position"}),
|
||||
fields: vec![
|
||||
Field::num("value[0]", "X", 10.0),
|
||||
Field::num("value[1]", "Y", 20.0),
|
||||
Field::choice("mode", "M", &[("A", json!("a")), ("B", json!("b"))], 1),
|
||||
],
|
||||
};
|
||||
assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! Smaller panels: Preview, Audio, History, Markers, Wiggler, the Home screen and placeholders.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dock::PanelKind;
|
||||
|
|
@ -51,6 +51,12 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add("preview.loop", lr, "Loop");
|
||||
p.text(pos2(lr.max.x + 6.0, yy), Align2::LEFT_CENTER, "Loop", Tokens::ui(12.0), t.text);
|
||||
let ar = Rect::from_min_size(pos2(rect.min.x + 90.0, yy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, ar, app.ui.preview_audio, &t, egui::Id::new("pv-audio")).clicked() {
|
||||
let _ = crate::menus::invoke(app, &ctx, "playback.audio", json!({}));
|
||||
}
|
||||
app.auto.add("preview.audio", ar, "Include Audio");
|
||||
p.text(pos2(ar.max.x + 6.0, yy), Align2::LEFT_CENTER, "Include Audio", Tokens::ui(12.0), t.text);
|
||||
yy += 24.0;
|
||||
row(&p, yy, "Range", "Work Area Extended By Current Time");
|
||||
yy += 22.0;
|
||||
|
|
@ -74,19 +80,92 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the
|
||||
/// audio preview; the selected layer's Audio Levels on the right.
|
||||
pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
use crate::audio::METER_FLOOR;
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
// Level meters (silent until audio playback lands).
|
||||
for (i, x) in [rect.min.x + 20.0, rect.min.x + 34.0].into_iter().enumerate() {
|
||||
let r = Rect::from_min_max(pos2(x, rect.min.y + 14.0), pos2(x + 10.0, rect.max.y - 20.0));
|
||||
p.rect_filled(r, 1.0, t.field_bg);
|
||||
let _ = i;
|
||||
let top = rect.min.y + 26.0;
|
||||
let bottom = rect.max.y - 22.0;
|
||||
if bottom - top < 40.0 {
|
||||
return;
|
||||
}
|
||||
for (k, db) in [0, -6, -12, -18, -24, -36, -48].iter().enumerate() {
|
||||
let y = rect.min.y + 14.0 + (rect.height() - 34.0) * (-*db as f32 / 48.0);
|
||||
p.text(pos2(rect.min.x + 52.0, y), Align2::LEFT_CENTER, format!("{db} dB"), Tokens::ui(10.0), t.text_faint);
|
||||
let _ = k;
|
||||
let y_of = |db: f32| top + (bottom - top) * (db.clamp(METER_FLOOR, 0.0) / METER_FLOOR);
|
||||
let m = app.meter;
|
||||
let green = Color32::from_rgb(0x3c, 0xc8, 0x5a);
|
||||
let yellow = Color32::from_rgb(0xe6, 0xc8, 0x3c);
|
||||
let red = Color32::from_rgb(0xe6, 0x46, 0x3c);
|
||||
let seg_col = |db: f32| {
|
||||
if db > -3.0 {
|
||||
red
|
||||
} else if db > -12.0 {
|
||||
yellow
|
||||
} else {
|
||||
green
|
||||
}
|
||||
};
|
||||
for c in 0..2 {
|
||||
let x = rect.min.x + 18.0 + c as f32 * 16.0;
|
||||
let bar = Rect::from_min_max(pos2(x, top), pos2(x + 12.0, bottom));
|
||||
p.rect_filled(bar, 1.0, t.field_bg);
|
||||
// Lit segments, 1.5 dB each, coloured by their level.
|
||||
let lvl = m.level_db[c];
|
||||
let mut db = METER_FLOOR;
|
||||
while db < lvl.min(0.0) {
|
||||
let hi = (db + 1.5).min(lvl);
|
||||
p.rect_filled(Rect::from_min_max(pos2(bar.min.x + 1.0, y_of(hi)), pos2(bar.max.x - 1.0, y_of(db) - 0.5)), 0.0, seg_col(db));
|
||||
db += 1.5;
|
||||
}
|
||||
if m.peak_db[c] > METER_FLOOR {
|
||||
let py = y_of(m.peak_db[c]);
|
||||
p.line_segment([pos2(bar.min.x, py), pos2(bar.max.x, py)], Stroke::new(2.0, seg_col(m.peak_db[c])));
|
||||
}
|
||||
// Clip indicator (click to reset).
|
||||
let clip = Rect::from_min_max(pos2(bar.min.x, top - 14.0), pos2(bar.max.x, top - 4.0));
|
||||
p.rect_filled(clip, 1.0, if m.clipped[c] { red } else { t.field_bg });
|
||||
let id = if c == 0 { "audio.clipLeft" } else { "audio.clipRight" };
|
||||
if ui.interact(clip, egui::Id::new(id), Sense::click()).clicked() {
|
||||
app.meter.clipped[c] = false;
|
||||
}
|
||||
app.auto.add(id, clip, if c == 0 { "Left clip indicator" } else { "Right clip indicator" });
|
||||
app.auto.add(if c == 0 { "audio.meterLeft" } else { "audio.meterRight" }, bar, &format!("{:.1} dB", m.level_db[c]));
|
||||
p.text(pos2(bar.center().x, bottom + 9.0), Align2::CENTER_CENTER, if c == 0 { "L" } else { "R" }, Tokens::ui(10.0), t.text_dim);
|
||||
}
|
||||
let sx = rect.min.x + 54.0;
|
||||
for db in [0, -6, -12, -18, -24, -30, -36, -42, -48] {
|
||||
let y = y_of(db as f32);
|
||||
p.line_segment([pos2(sx - 4.0, y), pos2(sx - 1.0, y)], Stroke::new(1.0, t.text_faint));
|
||||
p.text(pos2(sx + 2.0, y), Align2::LEFT_CENTER, format!("{db:.1}"), Tokens::ui(10.0), t.text_faint);
|
||||
}
|
||||
p.text(pos2(sx + 30.0, top - 9.0), Align2::LEFT_CENTER, "dB", Tokens::ui(10.0), t.text_dim);
|
||||
// Selected layer's Audio Levels.
|
||||
let lx = rect.min.x + 120.0;
|
||||
if rect.max.x - lx < 80.0 {
|
||||
return;
|
||||
}
|
||||
let sel = app.session.state.selected_layers.first().copied();
|
||||
let levels = sel.and_then(|id| {
|
||||
let c = app.session.active_comp()?;
|
||||
let l = c.layer(id)?;
|
||||
let pr = l.props.sub("audio")?.get("levels")?;
|
||||
Some((l.name.clone(), pr.value.as_vec2()))
|
||||
});
|
||||
match levels {
|
||||
Some((name, [a, b])) => {
|
||||
p.text(pos2(lx, top - 9.0), Align2::LEFT_CENTER, &name, Tokens::ui(11.0), t.text_dim);
|
||||
for (i, (k, v)) in [("Left", a), ("Right", b)].into_iter().enumerate() {
|
||||
let y = top + 12.0 + i as f32 * 20.0;
|
||||
p.text(pos2(lx, y), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim);
|
||||
p.text(pos2(lx + 44.0, y), Align2::LEFT_CENTER, format!("{v:+.1} dB"), Tokens::ui(12.0), t.hot_text);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
p.text(pos2(lx, top + 12.0), Align2::LEFT_CENTER, "No audio layer selected", Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
}
|
||||
if app.meter.active() || app.audio.is_some() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub mod dialogs;
|
|||
pub mod dialogs_3d;
|
||||
pub mod effect_controls;
|
||||
pub mod effects_presets;
|
||||
pub mod forms;
|
||||
pub mod graph;
|
||||
pub mod info;
|
||||
pub mod key_dialogs;
|
||||
|
|
@ -15,6 +16,7 @@ pub mod render_queue;
|
|||
pub mod text_panels;
|
||||
pub mod timeline;
|
||||
pub mod viewer;
|
||||
pub mod waveform;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{Comp, TimeDisplayStyle};
|
||||
|
|
|
|||
|
|
@ -38,12 +38,20 @@ enum RowKind {
|
|||
uid: u64,
|
||||
lines: usize,
|
||||
},
|
||||
/// Audio > Waveform of a footage layer with audio (drawn from the item's peak summary).
|
||||
Waveform {
|
||||
item: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
|
||||
const WAVE_BIT: u64 = 1 << 60;
|
||||
|
||||
/// Row height (expression editors grow with their text).
|
||||
fn row_height(row: &Row, rh: f32) -> f32 {
|
||||
match row.kind {
|
||||
RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0,
|
||||
RowKind::Waveform { .. } => rh * 3.0,
|
||||
_ => rh,
|
||||
}
|
||||
}
|
||||
|
|
@ -237,9 +245,15 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
"opacity" => ("transform", vec!["opacity"]),
|
||||
"anchor" => ("transform", vec!["anchor"]),
|
||||
"feather" => ("masks", vec!["feather"]),
|
||||
"levels" => ("audio", vec!["levels"]),
|
||||
_ => ("", vec![]),
|
||||
};
|
||||
match kind.as_str() {
|
||||
"waveform" => {
|
||||
if let Some(item) = super::waveform::audio_item(&app.session.project, l) {
|
||||
rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Waveform { item: item.0 } });
|
||||
}
|
||||
}
|
||||
"effects" => {
|
||||
if let Some(fx) = l.effects() {
|
||||
for g in fx.groups() {
|
||||
|
|
@ -282,6 +296,36 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"props" => {
|
||||
// Animation ▸ Reveal Properties…: the engine picked the uids.
|
||||
fn groups_in<'a>(g: &'a PropGroup, set: &std::collections::BTreeSet<u64>, out: &mut Vec<&'a PropGroup>) {
|
||||
for sg in g.groups() {
|
||||
if set.contains(&sg.uid) {
|
||||
out.push(sg);
|
||||
} else {
|
||||
groups_in(sg, set, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups = vec![];
|
||||
groups_in(&l.props, &tl.reveal_props, &mut groups);
|
||||
for g in groups {
|
||||
let open = tl.open_groups.contains(&g.uid);
|
||||
rows.push(Row {
|
||||
layer: l.id,
|
||||
depth: 1,
|
||||
kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None, eye: None },
|
||||
});
|
||||
if open {
|
||||
push_group(&mut rows, l, g, 2, &tl.open_groups);
|
||||
}
|
||||
}
|
||||
let mut found = vec![];
|
||||
collect_props(&l.props, &mut found, &|p| prop_visible(p, l) && tl.reveal_props.contains(&p.uid));
|
||||
for uid in found {
|
||||
rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid } });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut found = vec![];
|
||||
let root = if group.is_empty() {
|
||||
|
|
@ -319,6 +363,21 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
});
|
||||
if open {
|
||||
push_group(&mut rows, l, g, 2, &tl.open_groups);
|
||||
// Audio > Waveform > Waveform (footage with audio).
|
||||
if g.match_id == "audio"
|
||||
&& let Some(item) = super::waveform::audio_item(&app.session.project, l)
|
||||
{
|
||||
let wuid = WAVE_BIT | g.uid;
|
||||
let wopen = tl.open_groups.contains(&wuid);
|
||||
rows.push(Row {
|
||||
layer: l.id,
|
||||
depth: 2,
|
||||
kind: RowKind::Group { uid: wuid, name: "Waveform".into(), open: wopen, has_children: true, fx: None, eye: None },
|
||||
});
|
||||
if wopen {
|
||||
rows.push(Row { layer: l.id, depth: 3, kind: RowKind::Waveform { item: item.0 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
|
||||
|
|
@ -995,13 +1054,33 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, "Inverted", Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
let gr = ui.interact(Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)), egui::Id::new(("grow", uid)), Sense::click());
|
||||
if gr.clicked() {
|
||||
if gr.clicked() && uid & WAVE_BIT == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
}
|
||||
if gr.double_clicked() {
|
||||
ui_actions.push(UiAct::ToggleGroup(*uid));
|
||||
}
|
||||
}
|
||||
RowKind::Waveform { item } => {
|
||||
lp.rect_filled(left, 0.0, if ri % 2 == 0 { 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);
|
||||
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");
|
||||
match super::waveform::summary(app, &ctx, effectcraft_engine::project::ItemId(*item)) {
|
||||
Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
|
||||
None => {
|
||||
gp.text(
|
||||
pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"Building waveform…",
|
||||
Tokens::ui(11.0),
|
||||
t.text_faint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
RowKind::Expr { uid, lines } => {
|
||||
let Some(prop) = layer.props.find(*uid) else { continue };
|
||||
let Some(ex) = prop.expr.clone() else { continue };
|
||||
|
|
|
|||
|
|
@ -263,7 +263,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let bar_h = 30.0;
|
||||
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
|
||||
let area = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, bar.min.y));
|
||||
p.rect_filled(area, 0.0, t.pasteboard);
|
||||
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
|
||||
p.rect_filled(area, 0.0, pasteboard);
|
||||
|
||||
let (cw, ch) = (comp.width as f32, comp.height as f32);
|
||||
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
|
||||
|
|
@ -340,6 +341,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
// Comp guides (View ▸ Show Guides) and the region of interest.
|
||||
if app.ui.viewer.guides {
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgb(0x3c, 0xc8, 0xf0));
|
||||
for g in &comp.guides {
|
||||
if g.vertical {
|
||||
let x = comp_rect.min.x + g.position as f32 * zoom;
|
||||
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], stroke);
|
||||
} else {
|
||||
let y = comp_rect.min.y + g.position as f32 * zoom;
|
||||
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
|
||||
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
|
||||
}
|
||||
|
||||
// Overlays + interaction.
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
|
|
@ -1207,7 +1226,16 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32,
|
|||
app.ui.viewer.res = Resolution::ALL[i];
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest");
|
||||
let has_roi = app.session.state.region_of_interest.is_some();
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") {
|
||||
// Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest).
|
||||
let rect = if has_roi {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0])
|
||||
};
|
||||
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
|
||||
}
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
//! Audio waveforms for the Timeline's Audio > Waveform property (revealed with `LL`).
|
||||
//!
|
||||
//! Each footage item's audio is summarised once into min/max peaks
|
||||
//! (`effectcraft_render::audio::footage_peaks`, [`BINS_PER_SEC`] bins per second) on a
|
||||
//! background thread; the timeline draws from that summary at any zoom.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::project::{ItemId, ItemKind, Layer, LayerSource, Project};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Color32, Painter, Rect, Stroke, pos2};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
/// Peak-summary resolution.
|
||||
pub const BINS_PER_SEC: u32 = 200;
|
||||
|
||||
/// A footage item's peak summary: `[min L, max L, min R, max R]` per bin.
|
||||
pub struct Summary {
|
||||
pub bins_per_sec: u32,
|
||||
pub peaks: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// Summaries by footage item (`None` while computing).
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
map: Arc<Mutex<HashMap<u64, Option<Arc<Summary>>>>>,
|
||||
}
|
||||
|
||||
/// The footage item whose audio a layer plays, when it has audio.
|
||||
pub fn audio_item(project: &Project, l: &Layer) -> Option<ItemId> {
|
||||
let LayerSource::Footage { item } = &l.source else { return None };
|
||||
match &project.item(*item)?.kind {
|
||||
ItemKind::Footage(f) if f.has_audio && !f.missing => Some(*item),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The summary for `item`, starting its computation on first request.
|
||||
pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Option<Arc<Summary>> {
|
||||
let mut m = app.waveforms.map.lock().ok()?;
|
||||
if let Some(s) = m.get(&item.0) {
|
||||
return s.clone();
|
||||
}
|
||||
let ItemKind::Footage(f) = &app.session.project.item(item)?.kind else { return None };
|
||||
m.insert(item.0, None);
|
||||
let (f, footage, map, ctx) = (f.clone(), app.session.footage.clone(), app.waveforms.map.clone(), ctx.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("ec-waveform".into())
|
||||
.spawn(move || {
|
||||
let peaks = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC);
|
||||
if let Ok(mut m) = map.lock() {
|
||||
m.insert(item.0, Some(Arc::new(Summary { bins_per_sec: BINS_PER_SEC, peaks })));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw `layer`'s waveform in `rect` (left channel above right), columns mapped through the
|
||||
/// timeline's time map and the layer's time mapping, limited to its [in, out) span.
|
||||
pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summary, color: Color32) {
|
||||
let mid = [rect.min.y + rect.height() * 0.25, rect.min.y + rect.height() * 0.75];
|
||||
let half = rect.height() * 0.25 - 1.0;
|
||||
let x0 = tm.x(layer.in_point.seconds()).max(rect.min.x).floor() as i32;
|
||||
let x1 = tm.x(layer.out_point.seconds()).min(rect.max.x).ceil() as i32;
|
||||
let n = s.peaks.len() as i64;
|
||||
for c in 0..2 {
|
||||
p.line_segment([pos2(x0 as f32, mid[c]), pos2(x1 as f32, mid[c])], Stroke::new(1.0, color.gamma_multiply(0.5)));
|
||||
}
|
||||
for x in x0..x1 {
|
||||
let (ta, tb) = (tm.t(x as f32), tm.t(x as f32 + 1.0));
|
||||
let src = |t: f64| layer.layer_time(Tick::from_seconds_f64(t)).seconds() * s.bins_per_sec as f64;
|
||||
let (ba, bb) = (src(ta).floor() as i64, src(tb).ceil() as i64);
|
||||
let (ba, bb) = (ba.min(bb).clamp(0, n), ba.max(bb).max(ba + 1).clamp(0, n));
|
||||
if ba >= bb {
|
||||
continue;
|
||||
}
|
||||
let mut agg = [0.0f32; 4];
|
||||
for b in &s.peaks[ba as usize..bb as usize] {
|
||||
agg[0] = agg[0].min(b[0]);
|
||||
agg[1] = agg[1].max(b[1]);
|
||||
agg[2] = agg[2].min(b[2]);
|
||||
agg[3] = agg[3].max(b[3]);
|
||||
}
|
||||
for c in 0..2 {
|
||||
let (lo, hi) = (agg[c * 2].clamp(-1.0, 1.0), agg[c * 2 + 1].clamp(-1.0, 1.0));
|
||||
let (ya, yb) = (mid[c] - hi * half, mid[c] - lo * half);
|
||||
p.line_segment([pos2(x as f32 + 0.5, ya), pos2(x as f32 + 0.5, yb.max(ya + 1.0))], Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -199,6 +199,7 @@ impl Resolution {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ViewerState {
|
||||
/// Magnification (1 = 100%); None = fit.
|
||||
pub zoom: Option<f32>,
|
||||
|
|
@ -213,6 +214,14 @@ pub struct ViewerState {
|
|||
pub channel: String,
|
||||
pub show_layer_controls: bool,
|
||||
pub fast_preview: bool,
|
||||
/// View ▸ Show Guides / Snap to Guides / Lock Guides / Snap to Grid.
|
||||
pub guides: bool,
|
||||
pub snap_guides: bool,
|
||||
pub lock_guides: bool,
|
||||
pub snap_grid: bool,
|
||||
/// View ▸ Panel Background Color (`None` = theme default).
|
||||
pub pasteboard: Option<[u8; 3]>,
|
||||
pub custom_pasteboard: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for ViewerState {
|
||||
|
|
@ -229,6 +238,12 @@ impl Default for ViewerState {
|
|||
channel: "RGB".into(),
|
||||
show_layer_controls: true,
|
||||
fast_preview: false,
|
||||
guides: true,
|
||||
snap_guides: false,
|
||||
lock_guides: false,
|
||||
snap_grid: false,
|
||||
pasteboard: None,
|
||||
custom_pasteboard: [0x80, 0x80, 0x80],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +264,9 @@ pub struct TimelineState {
|
|||
pub open_groups: BTreeSet<u64>,
|
||||
/// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal.
|
||||
pub reveal: Vec<String>,
|
||||
/// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…).
|
||||
#[serde(default)]
|
||||
pub reveal_props: BTreeSet<u64>,
|
||||
/// Graph Editor: `value` or `speed` graph.
|
||||
#[serde(default = "value_graph")]
|
||||
pub graph_mode: String,
|
||||
|
|
@ -286,6 +304,7 @@ impl Default for TimelineState {
|
|||
open_layers: BTreeSet::new(),
|
||||
open_groups: BTreeSet::new(),
|
||||
reveal: vec![],
|
||||
reveal_props: BTreeSet::new(),
|
||||
graph_mode: value_graph(),
|
||||
graph_show_selected: true,
|
||||
graph_auto_zoom: true,
|
||||
|
|
@ -338,7 +357,12 @@ pub struct UiState {
|
|||
/// Preview panel options.
|
||||
pub preview_loop: bool,
|
||||
pub preview_cache_first: bool,
|
||||
/// Preview panel "Include Audio" (Mute Audio off).
|
||||
#[serde(default = "yes")]
|
||||
pub preview_audio: bool,
|
||||
pub start_screen: bool,
|
||||
/// Composition ▸ Preview ▸ Cache Frames When Idle.
|
||||
pub cache_when_idle: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -369,7 +393,9 @@ impl Default for UiState {
|
|||
tool_creates_shape: true,
|
||||
preview_loop: true,
|
||||
preview_cache_first: false,
|
||||
preview_audio: true,
|
||||
start_screen: false,
|
||||
cache_when_idle: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ its evaluated inputs, excluding the transform. Static and transform-only layers
|
|||
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
|
||||
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
|
||||
|
||||
**Time effects** (Echo, Posterize Time, Timewarp…) read the layer at other times through
|
||||
`EffectHost::self_at`: the renderer renders the layer's source and masks (plus, optionally, the
|
||||
effects before it) at that layer time via `Renderer::layer_input`, cached under separate *input*
|
||||
keys, with a nesting-depth guard.
|
||||
|
||||
**Audio** is mixed by `render::audio::mix_comp` in blocks: Audio switch, solo, Audio Levels and
|
||||
the Effect > Audio effects (`effects::audio_fx`, applied per layer with a pre-roll so blocks are
|
||||
independent). Export and preview playback share it; the desktop app plays it through cpal and the
|
||||
audio clock drives preview playback (`ui-egui::audio`).
|
||||
|
||||
**Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and
|
||||
may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the
|
||||
layer with their own blend modes; the interior styles, Stroke and Bevel and Emboss are baked into
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Useful query commands for `engine.execute` (they aren't journaled or undoable):
|
|||
| `ui.elements` | `{prefix?}` | `[{id, label, rect: [x, y, w, h]}]`: the widgets drawn in the last frame, in points. |
|
||||
| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. |
|
||||
| `ui.panel.show` / `ui.panel.close` | `{panel}` | Panels: `Project`, `Composition`, `Timeline`, `EffectControls`, `EffectsPresets`, `Info`, `Preview`, `Character`, `Paragraph`, `Align` and others. |
|
||||
| `ui.playback` | `{action: play\|stop\|toggle}` | `{playing, time}` |
|
||||
| `ui.playback` | `{action: play\|stop\|toggle\|status}` | `{playing, time, audio, levelsDb, peaksDb}` (audio: an audio preview is running; L/R meter levels and held peaks in dBFS) |
|
||||
| `ui.resize` | `{width, height}` | Resizes the window (points). |
|
||||
| `ui.focus` | `{}` | Activates the window and takes keyboard focus. Agents normally avoid this, because the app is started without stealing focus. |
|
||||
| `app.quit` | `{}` | Closes the app. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue