diff --git a/Cargo.lock b/Cargo.lock index db7b02f4..e1790bf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index bd1d4b20..ab4eef12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 3240c78d..5a2165aa 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -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). diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs new file mode 100644 index 00000000..bef9ff2e --- /dev/null +++ b/apps/effectcraft/src/audio_out.rs @@ -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, + stop: Option>, + thread: Option>, +} + +/// Open the default output device (`None` without one). +pub fn open() -> Option> { + 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>( + dev: &cpal::Device, + cfg: &cpal::StreamConfig, + feed: Arc, + latency: Arc, +) -> Result { + let ch = cfg.channels as usize; + let rate = cfg.sample_rate.0 as f64; + let mut stereo: Vec = 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) -> Result<(), String> { + self.stop(); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + let (ready_tx, ready_rx) = mpsc::channel::>(); + 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::(&dev, &cfg, feed, latency), + cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency), + cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency), + cpal::SampleFormat::I32 => run::(&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(); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 9e8bcd6f..2c6ca716 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -5,6 +5,7 @@ //! `--control ` (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()); diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs new file mode 100644 index 00000000..855fd10e --- /dev/null +++ b/crates/effects/src/audio_fx.rs @@ -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) -> 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 { + 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, 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, 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 = (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 { + (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 = 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")); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6b8b7a97..923a24e4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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>; + /// 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 { + 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 { + 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`]. diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs new file mode 100644 index 00000000..ee0f3e3c --- /dev/null +++ b/crates/effects/src/time_fx.rs @@ -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, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Time", params, render, gpu: false, float: true } +} + +pub fn specs() -> Vec { + 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, +} + +/// Fetch the layer (source + masks) at layer times `times`. `None` without a host. +fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option { + let host = ctx.env.host?; + // Dedupe times (sub-microsecond differences are the same frame). + let mut uniq: Vec = times.iter().map(|t| (t * 1e6).round() as i64).collect(); + uniq.sort_unstable(); + uniq.dedup(); + let bufs: Vec<(i64, Option)> = 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 = (0..=n).map(|i| ctx.time + i as f64 * dt).collect(); + let Some(fr) = fetch(ctx, ×) else { return b }; + let weights: Vec = (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 = 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 = idx.clone(); + keys.sort_unstable(); + keys.dedup(); + if keys == [0] { + return grid; + } + let times: Vec = keys.iter().map(|k| ctx.time + *k as f64 * step).collect(); + let Some(fr) = fetch(ctx, ×) else { return grid }; + // Frames come back on the union grid; map the current grid into it. + let dx = (fr.grid.offset[0] - grid.offset[0]).round() as i64; + let dy = (fr.grid.offset[1] - grid.offset[1]).round() as i64; + let w = grid.img.width as usize; + let mut out = grid.img.clone(); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let k = idx[y * w + x]; + let fi = keys.binary_search(&k).unwrap_or(0); + *o = fr.imgs[fi].get(x as i64 + dx, y as i64 + dy); + } + }); + with_img(grid, out) +} + +// ---------------------------------------------------------------- Timewarp + +/// Source layer time Timewarp shows at layer time `t`. +pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f64, fps: f64) -> f64 { + if by_frame { source_frame / fps } else { t * speed / 100.0 } +} + +fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { + if ctx.env.host.is_none() { + return b; + } + let fps = ctx.fps(); + let by_frame = ctx.params.e("adjustTimeBy") == 1; + let speed = ctx.params.f("speed"); + let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); + let method = ctx.params.e("method"); + // Sample times (with weights) for one instant of source time. + let instant = |s: f64| -> Vec<(f64, f32)> { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + let frac = (f - f0) as f32; + if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] } + }; + let mut samples: Vec<(f64, f32)> = Vec::new(); + if ctx.params.b("enableMotionBlur") { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let rate = if by_frame { 1.0 } else { speed / 100.0 }; + let span = angle / 360.0 * rate / fps; + for i in 0..n { + let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }; + samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32))); + } + } else { + samples = instant(src); + } + let times: Vec = samples.iter().map(|s| s.0).collect(); + let weights: Vec = samples.iter().map(|s| s.1).collect(); + let Some(fr) = fetch(ctx, ×) else { return b }; + let img = average(&fr.imgs, &weights); + with_img(fr.grid, img) +} + +// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur + +fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { + let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize; + let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 }; + let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect(); + mean_of(ctx, b, ×) +} + +fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf { + let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64; + let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64; + let fd = 1.0 / ctx.fps(); + let times: Vec = (-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect(); + mean_of(ctx, b, ×) +} + +fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); + mean_of(ctx, b, ×) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + + /// A layer whose every pixel at layer time `t` is grey `t` (clamped), alpha 1, on a 6×4 grid. + struct Clock { + static_layer: bool, + } + impl EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, effects: usize) -> Option { + 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); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ecc39243..33150379 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,3 +24,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +image = { workspace = true } diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index f3560ccd..0fbef4bb 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -386,7 +386,7 @@ pub fn specs() -> Vec { 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}))), diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs new file mode 100644 index 00000000..a6390504 --- /dev/null +++ b/crates/engine/src/commands/animation.rs @@ -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 { + 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 = 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 = 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 = 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 { + 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 = + 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 = 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 { + 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> { + let mut m: BTreeMap<(LayerId, Uid), Vec> = 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 { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fd = comp.frame_duration(); + let groups = keys_by_prop(s); + let mut done = 0; + let mut work = vec![]; + for ((lid, uid), mut times) in groups { + let Some(l) = comp.layer(lid) else { continue }; + if l.transform().and_then(|tr| tr.get("scale")).map(|p| p.uid) != Some(uid) { + continue; + } + times.sort(); + if times.len() >= 2 { + work.push((lid, uid, times[0], *times.last().unwrap_or(×[0]))); + } + } + if work.is_empty() { + return Err(bad("keys.exponentialScale", "select two Scale keyframes")); + } + s.edit("Exponential Scale", None, |proj, st| { + for (lid, uid, a, b) in &work { + let l = layer_mut(proj, cid, *lid)?; + let Some(pr) = l.props.find_mut(*uid) else { continue }; + let (va, vb) = (pr.value_at(*a).as_vec3(), pr.value_at(*b).as_vec3()); + pr.keys.retain(|k| k.time <= *a || k.time >= *b); + let n = ((*b - *a).0 / fd.0.max(1)).max(1); + for i in 0..=n { + let u = i as f64 / n as f64; + let t = if i == n { *b } else { *a + Tick(fd.0 * i) }; + let f = |x: f64, y: f64| if x.abs() < 1e-9 || y.abs() < 1e-9 || x.signum() != y.signum() { x + (y - x) * u } else { x * (y / x).powf(u) }; + let v = [f(va[0], vb[0]), f(va[1], vb[1]), f(va[2], vb[2])]; + let mut k = Keyframe::new(t, KV::Vec3(v)); + k.in_interp = Interp::Linear; + k.out_interp = Interp::Linear; + effectcraft_keyframe::set_key(&mut pr.keys, k); + st.selected_keys.push(KeyRef { layer: *lid, prop: *uid, time: t }); + } + done += 1; + } + st.selected_keys.sort_by_key(|k| (k.layer.0, k.prop, k.time)); + st.selected_keys.dedup(); + Ok(()) + })?; + Ok(json!({"properties": done})) +} + +// ---------------------------------------------------------------- text animators + +/// Animation ▸ Add Text Selector ▸ Range / Wiggly / Expression (see `layer.addTextSelector`). +fn add_selector(s: &mut Session, p: &Value) -> Result { + super::text_anim::add_selector(s, p) +} + +fn remove_all_animators(s: &mut Session, p: &Value) -> Result { + 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 { + 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 = 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index f00b9b51..996df5df 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -241,7 +241,7 @@ pub fn specs() -> Vec { 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 { ), 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 { 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), diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs new file mode 100644 index 00000000..6a066b5a --- /dev/null +++ b/crates/engine/src/commands/comp_more.rs @@ -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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 027c23b6..df7a9e42 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -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 { @@ -100,18 +119,187 @@ fn delete(s: &mut Session, p: &Value) -> Result { 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 { // 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { } fn paste(s: &mut Session, p: &Value) -> Result { + 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 { Ok(Value::Null) } +fn paste_reversed(s: &mut Session, _: &Value) -> Result { + 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 { + 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 = 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 = 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 { + work_area_cut(s, p, false) +} +fn extract(s: &mut Session, p: &Value) -> Result { + work_area_cut(s, p, true) +} + +fn select_label_group(s: &mut Session, _: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let labels: Vec