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:
Brandon Thomas 2026-10-02 00:44:20 -04:00
commit db21e880ef
58 changed files with 8571 additions and 396 deletions

268
Cargo.lock generated
View File

@ -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"

View File

@ -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"]

View File

@ -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).

View File

@ -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();
}
}

View File

@ -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());

View File

@ -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"));
}
}

View File

@ -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`].

View File

@ -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, &times) 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, &times) 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, &times) 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, &times)
}
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, &times)
}
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, &times)
}
#[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);
}
}

View File

@ -24,3 +24,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
image = { workspace = true }

View File

@ -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}))),

View File

@ -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(&times[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),
]
}

View File

@ -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),

View File

@ -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),
]
}

View File

@ -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),
]
}

View File

@ -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}",

View File

@ -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),
]
}

View File

@ -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),
]
}

View File

@ -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",

View File

@ -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",

View File

@ -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 &centers {
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
),
]
}

View File

@ -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)),
]
}

View File

@ -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)),
]
}

View File

@ -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 {

View File

@ -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?}",

View File

@ -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]}",

View File

@ -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}"),
]
}

View File

@ -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",

View File

@ -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,

View File

@ -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}))),

View File

@ -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),
]
}

View File

@ -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;

944
crates/engine/src/menus.rs Normal file
View File

@ -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;

View File

@ -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());
}

View File

@ -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}");
}
}

View File

@ -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");
}

View File

@ -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);
});
}

View File

@ -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,
}

View File

@ -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…).

View File

@ -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;

View File

@ -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);

View File

@ -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: &params, 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;

View File

@ -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]);
}

View File

@ -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}");
}
}

View File

@ -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).

345
crates/ui-egui/src/audio.rs Normal file
View File

@ -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]);
}
}

View File

@ -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));

View File

@ -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(),
}
}
}

View File

@ -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, &params)? {
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(&params) => crate::panels::dialogs::open_new_comp(app),
"app.compSettings" | "comp.settings" if no_params(&params) => crate::panels::dialogs::open_comp_settings(app)?,
"app.solidSettings" | "layer.newSolid" if no_params(&params) => crate::panels::dialogs::open_new_solid(app)?,
"keys.velocity" if no_params(&params) => crate::panels::key_dialogs::open_velocity(app)?,
"keys.interpolation" if no_params(&params) => crate::panels::key_dialogs::open_interpolation(app)?,
"layer.timeStretch" if no_params(&params) => 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, &params) {
return r;
}
if crate::panels::dialogs::open_form(app, id, &params) {
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);
}
}

View File

@ -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),

View File

@ -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"}));
}
}

View File

@ -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();
}
}

View File

@ -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};

View File

@ -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 };

View File

@ -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;
}

View File

@ -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));
}
}
}

View File

@ -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,
}
}
}

View File

@ -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

View File

@ -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. |