diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72992087..808e1628 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,3 +34,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 85827f74..ac20cd7c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -58,7 +58,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall ## Code-drawn assets (original work, MIT OR Apache-2.0) -- UI icons drawn in code: `crates/ui-egui/src/icons.rs`. +- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors). - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. - Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story, Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer: diff --git a/Cargo.lock b/Cargo.lock index c5d150f4..595f50d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1159,6 +1159,7 @@ dependencies = [ "objc2-foundation 0.3.2", "pollster 0.4.0", "rfd", + "serde", "serde_json", "winit", "winresource", @@ -1167,7 +1168,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1179,14 +1180,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1202,7 +1203,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -1210,7 +1211,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1227,7 +1228,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1260,7 +1261,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1290,7 +1291,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.8.0" +version = "0.9.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1307,7 +1308,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -1315,7 +1316,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1337,14 +1338,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1368,7 +1369,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1387,7 +1388,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1399,7 +1400,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1416,7 +1417,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-model", "effectcraft-pdf", @@ -1430,6 +1431,7 @@ dependencies = [ "filmcraft-codecs", "filmcraft-color", "filmcraft-frame", + "filmcraft-isobmff", "filmcraft-media", "filmcraft-time", "gif", @@ -1442,7 +1444,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-geom", "image", @@ -1454,14 +1456,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1473,7 +1475,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-raster", "effectcraft-svg", @@ -1487,7 +1489,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1499,7 +1501,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1512,14 +1514,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.8.0" +version = "0.9.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1528,7 +1530,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1550,7 +1552,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.8.0" +version = "0.9.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1558,6 +1560,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-path", "effectcraft-project", + "effectcraft-psd", "effectcraft-render", "effectcraft-time", "serde_json", @@ -1566,7 +1569,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.8.0" +version = "0.9.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1575,7 +1578,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1587,7 +1590,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1599,7 +1602,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -1607,7 +1610,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1619,7 +1622,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1651,11 +1654,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.8.0" +version = "0.9.0" [[package]] name = "effectcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6465,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 38ba10a0..4d6d49f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index aa363bea..72240b5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had +Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had native-speaker review. ## Upcoming @@ -117,6 +117,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel + catalog gained since 9 October are translated: the Home screen (recent files, their Kind and + Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification, + resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog + and the project, import, export and colour-management forms. `uk` now carries 495 of the 496 + panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by + design — alongside its 618/618 menus. - 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5fabc10c..5ad6c80d 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -16,7 +16,11 @@ effectcraft-ui-egui = { workspace = true } egui = { workspace = true } # `links`: links (Help menu, Discord, Settings) open in the system browser. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } +serde = { workspace = true } serde_json = { workspace = true } +# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app +# activation when driven by an agent). The same winit eframe runs on. +winit = { version = "0.30", default-features = false } rfd = { workspace = true } pollster = { workspace = true } cpal = { workspace = true } @@ -32,8 +36,6 @@ zbus = "5.19" winresource = "0.1" [target.'cfg(target_os = "macos")'.dependencies] -# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). -winit = { version = "0.30", default-features = false } # Keep the control channel responsive when the window is covered (App Nap / occlusion throttling). objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] } # The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build. diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index 7d0ec4b2..b4bdebb3 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -117,7 +117,7 @@ fn run>( 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}"), + |e| log::warn!("audio output: {e}"), None, ) .map_err(|e| e.to_string()) @@ -187,7 +187,7 @@ impl AudioDevice for CpalOut { return; } if let Err(e) = stream.play() { - eprintln!("effectcraft: audio output: {e}"); + log::warn!("audio output: {e}"); return; } let _ = stop_rx.recv(); diff --git a/apps/effectcraft/src/control_server.rs b/apps/effectcraft/src/control_server.rs index e5af3999..0ae8bfab 100644 --- a/apps/effectcraft/src/control_server.rs +++ b/apps/effectcraft/src/control_server.rs @@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; - eprintln!("effectcraft: control server listening on 127.0.0.1:{port}"); + log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 1b5f74f3..83015f1a 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -24,6 +24,7 @@ mod native_menu; // Built everywhere so it stays checked; only the macOS app uses it. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod sheet_dialogs; +mod window; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; @@ -88,13 +89,24 @@ fn parse_args(args: impl IntoIterator, env_port: Option) } fn main() -> eframe::Result { - // Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report. + // First, so the panic hook and every start-up warning are recorded. Records go to stderr + // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings + // feed the compatibility report. effectcraft_engine::logging::install(); effectcraft_engine::logging::install_panic_hook(); let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else { println!("effectcraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); }; + // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs + // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so + // `--version` leaves no file behind; records logged until now are written to it first. + if let Some(dir) = effectcraft_host::config_dir() { + match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) { + Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + Err(e) => log::warn!("no log file: {e}"), + } + } // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. @@ -108,8 +120,8 @@ fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("EffectCraft") - .with_inner_size([1680.0, 1020.0]) - .with_min_inner_size([960.0, 600.0]) + .with_inner_size(window::DEFAULT_SIZE) + .with_min_inner_size(window::MIN_SIZE) .with_drag_and_drop(true) // Started for an agent (`--control`): open without taking the user's keyboard focus. .with_active(control_port.is_none()) @@ -120,8 +132,8 @@ fn main() -> eframe::Result { wgpu_options: wgpu_options(launch.backends), ..Default::default() }; - // Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never - // appears shows how far start-up got (#234). + // Start-up milestones go to stderr and the session log, so a window that never appears + // shows how far start-up got (#234). log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION")); // Registered before the event loop starts, so it catches the Finder event that launched us as // well as later ones. Lives until the event loop returns; the app creator only borrows it. @@ -170,11 +182,14 @@ fn main() -> eframe::Result { session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir))); } session.load_settings(); + // Fit the window to its monitor, or put it back where it was (still hidden). + let saved_window = session.config.as_deref().and_then(window::load); + let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { if let Err(e) = session.execute("file.open", json!({"path": p})) { - eprintln!("effectcraft: {e}"); + log::warn!("{p}: {e}"); } } else if demo { let _ = session.execute("file.openDemoProject", json!({})); @@ -183,7 +198,7 @@ fn main() -> eframe::Result { if !media.is_empty() && let Err(e) = session.execute("file.import", json!({"paths": media})) { - eprintln!("effectcraft: {e}"); + log::warn!("import: {e}"); } let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none()); // The ScriptUI panels open when the app last quit open again (a failing one is @@ -264,6 +279,7 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] apple_events: apple_events.connect(&cc.egui_ctx), app, + window: window::Tracker::new(opened, saved_window, control_port.is_none()), launch, drawn: false, })) @@ -315,8 +331,12 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) { let message = format!( "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message." ); - log::error!("{message}"); - eprintln!("effectcraft: {message}"); + // Standard error and the session log; standard error alone when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{message}"); + } else { + eprintln!("effectcraft: {message}"); + } let _ = rfd::MessageDialog::new() .set_level(rfd::MessageLevel::Error) .set_title("EffectCraft") @@ -333,6 +353,8 @@ struct Desktop { /// Documents opened from Finder, the Dock or `open` (macOS). #[cfg(target_os = "macos")] apple_events: apple_events::Opened, + /// The window's size and position, saved for the next launch. + window: window::Tracker, launch: launch_guard::Launch, /// The first frame was drawn (logged once). drawn: bool, @@ -347,6 +369,7 @@ impl eframe::App for Desktop { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.app.ui(ui, frame); + self.window.frame(ui.ctx(), self.app.session.config.as_deref()); if !self.drawn { self.drawn = true; log::info!("first frame drawn"); @@ -360,6 +383,7 @@ impl eframe::App for Desktop { fn on_exit(&mut self) { self.launch.exited(); + self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); } diff --git a/apps/effectcraft/src/window.rs b/apps/effectcraft/src/window.rs new file mode 100644 index 00000000..6826edf0 --- /dev/null +++ b/apps/effectcraft/src/window.rs @@ -0,0 +1,371 @@ +//! The main window's size and position. The first launch fits the window to its monitor, opening +//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore +//! the size, position and maximized state saved in the config directory (`window.json`), moved +//! onto a monitor that is still connected. + +use effectcraft_engine::config::ConfigStore; +use serde::{Deserialize, Serialize}; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; + +const FILE: &str = "window.json"; +const MAX_BYTES: usize = 4096; +/// Seconds a window opened maximized gets to say so before its size is tracked. +const MAXIMIZE_WAIT: f64 = 2.0; + +/// The window's geometry between launches. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowGeometry { + /// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland). + pub pos: Option<[i32; 2]>, + /// The inner size in points; while maximized, the size to return to. + pub size: [f32; 2], + pub maximized: bool, +} + +/// The first window's size, in points. +pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0]; +/// The smallest window, in points. +pub const MIN_SIZE: [f32; 2] = [960.0, 600.0]; +/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't +/// say how much of the screen they take): a monitor with less room opens it maximized. +const SCREEN_ROOM: [f32; 2] = [64.0, 128.0]; + +/// A monitor: its top-left corner and size in physical pixels, and its scale factor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Monitor { + pos: [i64; 2], + size: [i64; 2], + scale: f32, +} + +impl Monitor { + pub fn new(pos: PhysicalPosition, size: PhysicalSize, scale: f64) -> Self { + let scale = scale as f32; + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale } + } + + /// Its size in points. + fn points(&self) -> [f32; 2] { + self.size.map(|s| s as f32 / self.scale) + } + + /// Its size in points less room for a taskbar, Dock or menu bar. + fn usable(&self) -> [f32; 2] { + let points = self.points(); + std::array::from_fn(|i| points[i] - SCREEN_ROOM[i]) + } + + /// A window of `size` points on this monitor, in physical pixels. + fn pixels(&self, size: [f32; 2]) -> [i64; 2] { + size.map(|s| (s * self.scale) as i64) + } + + /// How much of a window at `pos` of `size` points this monitor shows, in square pixels. + fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 { + let px = self.pixels(size); + let span = |i: usize| { + let start = i64::from(pos[i]).max(self.pos[i]); + let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]); + (end - start).max(0) + }; + span(0).saturating_mul(span(1)) + } + + /// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size` + /// points on this monitor. + fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] { + let px = self.pixels(size); + std::array::from_fn(|i| { + let lo = self.pos[i]; + let hi = lo + self.size[i].saturating_sub(px[i]).max(0); + let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i])); + i32::try_from(want.max(lo).min(hi)).unwrap_or_default() + }) + } +} + +/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else +/// the default size on `home` (the monitor the system opens it on). Without monitors (the system +/// doesn't list them, so nothing to check a saved geometry against) the default size. +pub fn place(saved: Option, monitors: &[Monitor], home: Option) -> WindowGeometry { + let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }; + let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else { + return default; + }; + let Some(saved) = saved else { + let usable = home.usable(); + if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) { + return default; + } + // A small screen: maximized, with a size that fits once un-maximized. + let size = fit(DEFAULT_SIZE, usable); + return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true }; + }; + // The monitor showing most of the window; none when it was on a monitor that is gone. + let shown = saved.pos.and_then(|p| { + let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?; + (area > 0).then_some(m) + }); + let m = shown.unwrap_or(home); + // Bigger than its monitor (a smaller screen than last time): maximized, like the first window. + let room = m.points(); + let too_big = (0..2).any(|i| saved.size[i] > room[i]); + let size = fit(saved.size, if too_big { m.usable() } else { room }); + WindowGeometry { + // Moved fully onto its monitor, or centred on the home monitor. + pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)), + size, + maximized: saved.maximized || too_big, + } +} + +/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is. +fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] { + std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0)) +} + +/// Size and place the new window (eframe shows it after drawing the first frame) and return its +/// geometry. +pub fn restore(window: &winit::window::Window, saved: Option) -> WindowGeometry { + let handles: Vec<_> = window.available_monitors().collect(); + let monitors: Vec = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect(); + let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c)); + let g = place(saved, &monitors, home); + if let Some([x, y]) = g.pos { + window.set_outer_position(PhysicalPosition::new(x, y)); + } + // Returns the new size when it applies at once; either way the window gets it. + let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1])); + if g.maximized { + window.set_maximized(true); + } + g +} + +/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps +/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size. +pub fn track(ctx: &egui::Context, geometry: &mut Option) { + let zoom = ctx.zoom_factor(); + ctx.input(|i| { + let v = i.viewport(); + if v.minimized == Some(true) || v.fullscreen == Some(true) { + return; + } + if v.maximized == Some(true) { + if let Some(g) = geometry { + g.maximized = true; + } + return; + } + let Some(inner) = v.inner_rect else { return }; + let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32)); + *geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false }); + }); +} + +/// The geometry saved by the last session; `None` when there is none or it can't be read. +pub fn load(config: &dyn ConfigStore) -> Option { + let text = config.read_bounded(FILE, MAX_BYTES).ok()??; + serde_json::from_str(&text).ok() +} + +/// Follows the window and saves its geometry for the next launch: when it has changed (at most +/// once a second, so a session that ends badly still has it) and on exit. +pub struct Tracker { + geometry: Option, + saved: Option, + last_attempt: f64, + /// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the + /// window's first frames report the maximized size as an ordinary one, which would replace + /// the size to return to. + maximizing: Option, + /// Agent-driven runs (`--control`) leave the user's saved window alone. + save: bool, +} + +impl Tracker { + /// `geometry`: what the window opened with. + pub fn new(geometry: Option, saved: Option, save: bool) -> Self { + let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY); + Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save } + } + + /// Per frame. + pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) { + let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true))); + if let Some(until) = self.maximizing { + // A system that never maximizes the window is believed after a moment. + let until = until.min(now + MAXIMIZE_WAIT); + self.maximizing = (!maximized && now < until).then_some(until); + } + if self.maximizing.is_none() { + track(ctx, &mut self.geometry); + } + if now - self.last_attempt >= 1.0 { + self.last_attempt = now; + self.save(config); + } + } + + /// Write the geometry when it differs from the saved one. + pub fn save(&mut self, config: Option<&dyn ConfigStore>) { + let (Some(config), Some(g)) = (config, self.geometry) else { return }; + if !self.save || self.saved == Some(g) { + return; + } + match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) { + Ok(()) => self.saved = Some(g), + Err(e) => log::warn!("could not save the window geometry: {e}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::config::MemoryConfig; + + fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor { + Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale) + } + + fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option { + Some(WindowGeometry { pos, size, maximized }) + } + + #[test] + fn the_first_window_fits_its_screen() { + // 2560 × 1440: the default size, where the system puts it. + let big = [monitor(0, 0, 2560, 1440, 1.0)]; + assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }); + // 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): + // maximized, and a size that fits the screen with room for the taskbar once un-maximized. + for (m, size) in [ + (monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]), + (monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]), + (monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]), + (monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]), + ] { + let g = place(None, &[m], Some(0)); + assert!(g.maximized, "{m:?}"); + assert_eq!(g.size, size, "{m:?}"); + let [x, y] = g.pos.unwrap(); + let px = m.pixels(g.size); + assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}"); + } + // No monitor information: the default size, whatever was saved. + assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }); + assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE); + } + + #[test] + fn the_saved_window_comes_back_where_it_was() { + let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)]; + for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] { + assert_eq!(Some(place(g, &two, Some(0))), g); + } + } + + #[test] + fn a_saved_window_stays_on_a_connected_monitor() { + let laptop = [monitor(0, 0, 1600, 900, 1.0)]; + // Last time on a second monitor that is gone: centred on the remaining one. + let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false }); + // Half off the monitor's right and bottom edges: moved fully onto it. + let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0)); + assert_eq!(g.pos, Some([600, 200])); + // Bigger than the screen now (it was on a larger one): it fits, and opens maximized. + let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true }); + // A monitor left of the primary one (negative coordinates) still counts. + let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)]; + assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40])); + // Nonsense sizes from a damaged preferences file are kept within the screen. + let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true }); + let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true }); + } + + #[test] + fn the_window_geometry_is_tracked_for_the_next_launch() { + let ctx = egui::Context::default(); + let mut geometry = None; + let mut frame = |info: egui::ViewportInfo| { + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, info); + ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear(); + geometry + }; + let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0)); + let normal = egui::ViewportInfo { + native_pixels_per_point: Some(1.5), + inner_rect: Some(rect), + outer_rect: Some(rect), + maximized: Some(false), + ..Default::default() + }; + // Position in physical pixels, size in points. + let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false }; + assert_eq!(frame(normal.clone()), Some(g)); + // Maximized or minimized: the size to restore stays. + let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0)); + let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() }; + assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g })); + let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() }; + assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g })); + assert_eq!(frame(normal), Some(g)); + } + + /// A window restored maximized keeps the size it returns to, though its first frames report + /// the maximized size as an ordinary one. + #[test] + fn opening_maximized_keeps_the_size_to_return_to() { + let ctx = egui::Context::default(); + let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true }; + let mut tracker = Tracker::new(Some(g), Some(g), true); + let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0)); + let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0)); + let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| { + let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() }; + let mut input = egui::RawInput { time: Some(time), ..Default::default() }; + input.viewports.insert(egui::ViewportId::ROOT, info); + ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear(); + tracker.geometry + }; + assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g)); + assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g)); + // Un-maximized by the user afterwards: tracked as usual. + let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false }; + assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored)); + // A system that doesn't maximize: the window's real size is tracked after the wait. + let mut tracker = Tracker::new(Some(g), Some(g), true); + assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g)); + assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored)); + } + + /// The reported bug: the window opened at the default size and place on every launch. + #[test] + fn the_window_geometry_is_saved_and_loaded() { + let config = MemoryConfig::default(); + assert_eq!(load(&config), None); + let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true }; + let mut tracker = Tracker::new(Some(g), None, true); + tracker.save(Some(&config)); + assert_eq!(load(&config), Some(g)); + // Positions the system doesn't report (Wayland) round-trip too. + tracker.geometry = Some(WindowGeometry { pos: None, ..g }); + tracker.save(Some(&config)); + assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g })); + // An agent-driven run leaves the saved window alone. + Tracker::new(Some(g), None, false).save(Some(&config)); + assert_eq!(load(&config).and_then(|g| g.pos), None); + // A damaged or oversized file is ignored: the window opens at its default. + for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] { + config.write(FILE, text).unwrap(); + assert_eq!(load(&config), None, "{text:.20}"); + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index d919502d..6a535121 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -58,4 +58,9 @@ sync_github_name = false [people.vangel76] real_name = "Valentin Klein" display_name = "Valentin Klein" + +[people.Losminek] sync_github_name = true + +[people.webdevtodayjason] +display_name = "Jason Brashear" diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 3ad4d6de..e86adec9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -264,6 +264,21 @@ impl Buf { pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } + /// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see + /// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content + /// keeps its place; the new area is transparent. No-op when the buffer already covers it. + pub fn cover(&mut self, size: [f64; 2]) { + let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return }; + let src = std::mem::replace(&mut self.img, Image::new(w, h)); + let (sw, sh) = (src.width as usize, src.height as usize); + for y in 0..sh { + let s = y * sw; + let d = (y + dy as usize) * w as usize + dx as usize; + self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]); + } + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + } /// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`. /// Moves between layer space and effect space (the renderer's `effect_bounds`). pub fn rebase(&mut self, origin: [f64; 2]) { @@ -281,6 +296,27 @@ impl Buf { } } +/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to +/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the +/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer +/// already covers that region unchanged (footage, solids, the comp below an adjustment). +pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { + if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { + return None; + } + // Region and content, both as pixel ranges in the buffer's own coordinates. + let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil()); + let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor()); + let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil()); + let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y); + if !(nw_f <= 16384.0 && nh_f <= 16384.0) { + return None; + } + let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32); + let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32); + (dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh)) +} + /// A layer mask at the current time, flattened to a polyline in layer coordinates. #[derive(Clone, Debug, Default, PartialEq)] pub struct MaskShape { diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index fdc3140c..88a5c01d 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -429,7 +429,8 @@ pub fn overflow(v: f32, mode: u32) -> f32 { } /// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend -/// With Original. +/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within +/// its masks (#939). pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] { let src = [val * opacity, val * opacity, val * opacity, opacity]; let out = match mode { @@ -440,7 +441,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend } }; let k = 1.0 - blend_orig; - [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k) + [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k) } fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf { @@ -574,6 +575,28 @@ mod tests { assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01)); } + #[test] + fn noise_stays_inside_the_layer_alpha() { + // #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer. + let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]); + for (i, p) in img.data.iter_mut().enumerate() { + let a = match i % 40 { + 0..10 => 0.0, + 10..20 => 0.5, + _ => 1.0, + }; + *p = p.map(|c| c * a); + } + for mode in [BLEND_NONE, BLEND_NORMAL, 3] { + let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img; + for (o, p) in img.data.iter().zip(&out.data) { + assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]); + assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha"); + } + assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask"); + } + } + #[test] fn sub_settings_and_fractal_types_change_the_result() { let base = run("ec.noise.fractal", &[]); diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index a1060c85..c700123d 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -5,7 +5,8 @@ use std::collections::BTreeMap; use effectcraft_keyframe::{Interp, Keyframe, Value as KV}; -use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [ 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, + // Separate Dimensions: X and Y Position default to the comp centre. + ("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6, + ("transform", "positionY") => (v.as_f64() - comp_center[1]).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]), @@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [ } } +/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose +/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path +/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's). +fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option { + let mut next = 0; + let mut ids = Ids(&mut next); + let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }); + let mut g = match top.match_id.as_str() { + "transform" if !camera_or_light => return None, + // Added to a layer after it is made (a source with audio, an Advanced 3D comp). + "audio" => build::audio(&mut ids), + "geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids), + "transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(), + _ => return None, + }; + match top.match_id.as_str() { + // Source Text and the animators are the layer's content, not options with a default. + "text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")), + // A model's scale and clip come from its file. + "geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")), + _ => {} + } + Some(g) +} + +/// Static properties of `g` whose value differs from the same property of `default` (matched by +/// match id; properties `default` lacks have no known default and are left out). +fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec) { + for c in &g.children { + match c { + Node::Prop(pr) => { + if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) { + out.push(pr.uid); + } + } + Node::Group(sg) => { + if let Some(d) = default.sub(&sg.match_id) { + changed_from(sg, d, out); + } + } + } + } +} + 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)?; @@ -312,10 +360,27 @@ fn reveal(s: &mut Session, p: &Value) -> Result { 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]; + // Properties at the top of the layer's tree (Time Remap): enabled time remapping is a + // modification in itself. + for pr in l.props.props() { + let hit = match kind.as_str() { + "keyframes" => pr.is_animated(), + "animation" => pr.is_animated() || pr.has_expression(), + _ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center), + }; + if hit { + out.push(json!({"layer": lid.0, "prop": pr.uid})); + } + } + // Modified: a fresh layer of the same kind gives the defaults to compare against. + let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None)); + let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }); 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(), + // A camera's or light's Transform is compared with a fresh one's below. + _ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(), _ => modified(pr, &top.match_id, anchor, center), }; let mut found = vec![]; @@ -335,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result { found.push(g.uid); } } + if let Some(fresh) = &fresh { + if let Some(default) = default_group(top, l, fresh) { + changed_from(top, &default, &mut found); + } + // Every layer style counts in full, like an effect; so does each overridden + // Essential Property. + if top.match_id == effectcraft_project::styles::GROUP { + found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid)); + } + if let GroupKind::Essential { overridden } = &top.kind { + for u in overridden { + if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) { + found.push(*u); + } + } + } + } out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u}))); } } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 9d03e344..9f6b7c99 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?; - let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?; s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved an XML copy to {path}")); Ok(json!({"path": path, "bytes": xml.len()})) @@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result { s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) }); return Ok(json!({"needsConfirmation": path})); } - let url = format!("file://{}", path.replace(' ', "%20")); + let url = super::layer_menu::file_url(path); s.events.push(Event::OpenUrl(url)); Ok(json!({"opened": path})) } @@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result { } fn reveal_log(s: &mut Session, _: &Value) -> Result { - let file = crate::logging::current_file().unwrap_or_else(|| log_file(s)); + // Enable Logging's file while it is on, else this run's session log. + let file = crate::logging::current_file() + .filter(|_| crate::logging::is_enabled()) + .or_else(crate::logging::session_file) + .or_else(crate::logging::current_file) + .unwrap_or_else(|| log_file(s)); let path = file.to_string_lossy().to_string(); let url = super::layer_menu::folder_url(&path); s.events.push(Event::OpenUrl(url.clone())); @@ -616,3 +621,25 @@ pub fn specs() -> Vec { query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))), ] } + +#[cfg(test)] +mod tests { + use serde_json::json; + + /// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`). + #[test] + fn execute_file_opens_an_encoded_file_url() { + let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("run #1 100%.txt"); + std::fs::write(&f, "x").unwrap(); + let mut s = crate::Session::default(); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap(); + let expected = super::super::layer_menu::file_url(&f.to_string_lossy()); + assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}"); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 6ed3a16d..4bfd2b2b 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -109,6 +109,7 @@ pub fn specs() -> Vec { label: "Run Commands (Batch)", menu: &[], shortcut: None, + alt_shortcuts: &[], params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}", enabled: always, run: batch, diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index bf1fd530..3fcf9c6d 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -1,7 +1,7 @@ //! Composition menu. use effectcraft_color::Label; -use effectcraft_project::{Comp, ItemId, ItemKind, Marker}; +use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option { /// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later). fn check_rate(p: &Value, cmd: &str) -> Result<()> { match f_p(p, "frameRate").or(f_p(p, "fps")) { - Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")), + Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")), _ => Ok(()), } } @@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> { fn apply_settings(c: &mut Comp, p: &Value) { let (ow, oh) = (c.width, c.height); if let Some(w) = p.get("width").and_then(Value::as_u64) { - c.width = (w as u32).clamp(4, 30000); + c.width = limits::size(w).max(4); } if let Some(h) = p.get("height").and_then(Value::as_u64) { - c.height = (h as u32).clamp(4, 30000); + c.height = limits::size(h).max(4); } // Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center … // 8 = bottom right). Unparented layers (and all their position keys) move by that offset. @@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { keep_work_area_ordered(c); } if let Some(pa) = f_p(p, "pixelAspect") { - c.pixel_aspect = pa.max(0.1); + c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT)); } if let Some(Value::Array(bg)) = p.get("background") { let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32; @@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) { c.shutter_phase = a.clamp(-360.0, 360.0); } if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) { - c.motion_blur_samples = (n as u32).clamp(2, 64); + c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32; } if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) { - c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256); + c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32; } if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) { c.preserve_frame_rate = b; diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 1e753c20..f1511558 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -5,8 +5,9 @@ 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}; +use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use crate::prefs::CompPreset; +use crate::{EngineError, Result, Session, cmd, query}; fn has_roi(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; @@ -17,7 +18,8 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> { /// (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); + // At most 30,000 px a side, like any composition (#788). + let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3])); s.edit(label, None, |proj, st| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; c.width = w; @@ -163,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result { Ok(json!({"start": start.seconds(), "end": end.seconds()})) } +// ---------------------------------------------------------------- composition presets + +/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the +/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced. +fn save_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.savePreset"; + let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?; + let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`"))); + let dim = |k: &str| { + num(k).and_then(|v| { + if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) } + }) + }; + let preset = + CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? }; + if !preset.is_valid() { + return Err(bad( + C, + format!( + "not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters", + CompPreset::MAX_NAME + ), + )); + } + let presets = &mut s.prefs.comp_presets; + let replaced = match presets.iter_mut().find(|q| q.name == preset.name) { + Some(q) => { + *q = preset; + true + } + None => { + presets.push(preset); + false + } + }; + s.save_prefs(); + s.prefs_revision += 1; + Ok(json!({"name": name, "replaced": replaced})) +} + +/// `comp.deletePreset`: forget a saved composition preset. +fn delete_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.deletePreset"; + let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let before = s.prefs.comp_presets.len(); + s.prefs.comp_presets.retain(|q| q.name != name); + if s.prefs.comp_presets.len() == before { + return Err(bad(C, format!("no saved preset named `{name}`"))); + } + s.save_prefs(); + s.prefs_revision += 1; + Ok(Value::Null) +} + +/// `comp.presets`: the saved composition presets. +fn list_presets(s: &mut Session, _: &Value) -> Result { + serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string())) +} + pub fn specs() -> Vec { vec![ cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi), @@ -177,6 +238,17 @@ pub fn specs() -> Vec { save_frame ), cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame), + cmd!( + "comp.savePreset", + "Save Composition Preset", + [], + None, + "{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)", + always, + save_preset + ), + cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset), + query!("comp.presets", "Composition Presets", "{}", list_presets), cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time), ] } diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/commands/content_fill.rs index 9ff0e157..650df5e7 100644 --- a/crates/engine/src/commands/content_fill.rs +++ b/crates/engine/src/commands/content_fill.rs @@ -405,6 +405,7 @@ pub fn specs() -> Vec { label: "Generate Fill Layer", menu: &[], shortcut: None, + alt_shortcuts: &[], params: P, enabled: has_layers, run: generate, @@ -415,6 +416,7 @@ pub fn specs() -> Vec { label: "Content-Aware Fill Layer...", menu: &["Layer", "New"], shortcut: None, + alt_shortcuts: &[], params: P, enabled: always, run: open_panel, diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 55e6c828..7f134c6b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -809,7 +809,7 @@ fn original_path(s: &Session) -> Option { fn edit_original(s: &mut Session, _: &Value) -> Result { let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?; - let url = format!("file://{path}"); + let url = super::layer_menu::file_url(&path); s.events.push(crate::Event::OpenUrl(url.clone())); Ok(json!({"url": url})) } @@ -938,3 +938,25 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + /// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains. + #[test] + fn edit_original_opens_an_encoded_file_url() { + let mut s = crate::Session::default(); + let id = s + .edit("add", None, |p, _| { + let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() }; + Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f))) + }) + .unwrap(); + s.state.project_selection = vec![id]; + s.drain_events(); + let r = s.execute("edit.editOriginal", json!({})).unwrap(); + assert_eq!(r["url"], "file:///clips/take%20%231.mov"); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov"))); + } +} diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/commands/expr_tools.rs index d266b94f..fae74a5b 100644 --- a/crates/engine/src/commands/expr_tools.rs +++ b/crates/engine/src/commands/expr_tools.rs @@ -43,8 +43,18 @@ impl ExprError { pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) }; + errors_in(&ctx, s.expr_check, || true).unwrap_or_default() +} + +/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between +/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and +/// syntax errors on disabled expressions, just like the explicit `expr.errors` query. +pub fn errors_in(ctx: &EvalCtx, check: Option std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option> { let mut out = vec![]; - for (i, l) in comp.layers.iter().enumerate() { + for (i, l) in ctx.comp.layers.iter().enumerate() { + if !keep_going() { + return None; + } let mut props: Vec<(&Property, &Expression)> = vec![]; l.props.walk("", &mut |_, p| { if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) { @@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec { } }); for (p, e) in props { + if !keep_going() { + return None; + } let msg = if e.enabled { match &ctx.expr { - Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(), + Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(), None => None, } } else { - s.expr_check.and_then(|check| check(&e.text).err()) + check.and_then(|check| check(&e.text).err()) }; if let Some(message) = msg { - out.push(error_of(cid, i, l, p, message, !e.enabled)); + out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled)); } } } - out + keep_going().then_some(out) } fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError { diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 6f3d2e70..74ec861e 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; - let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + let (mut proj, repaired) = + Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; resolve_relative_footage(&mut proj, path, s.services.as_ref()); s.replace_project(proj, Some(path.to_string())); + s.restore_view(&effectcraft_project::saved_view(&text)); s.note_project_path(path); + // Values past the limits (a corrupt or hostile file) were brought within them (#788). + if !repaired.is_empty() { + let more = repaired.len().saturating_sub(3); + let shown = repaired.iter().take(3).cloned().collect::>().join("; "); + let more = if more > 0 { format!(" and {more} more") } else { String::new() }; + s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}.")); + } // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. let saved_by = effectcraft_project::saved_by(&text); if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) { @@ -60,7 +69,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { if s.check_footage_on_open { crate::footage_check::start(s, None, false)?; } - Ok(json!({"path": path, "savedBy": saved_by})) + Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired})) } /// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written @@ -89,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. pub(crate) fn file_text(s: &Session, path: &str) -> Result { - let json = s.project.to_file_json()?; + let json = s.project.to_file_json_with_view(&s.saved_view())?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9aecd8d6..ef26d59b 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result { } fn rate_of(p: &Value) -> FrameRate { - f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97) + f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97) } fn placeholder_footage(p: &Value) -> Footage { - let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32; - let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32; + let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size); + let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size); Footage { path: String::new(), kind: FootageKind::Video, @@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid { .unwrap_or([0.5, 0.5, 0.5]); Solid { color, - width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1), - height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1), + width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1), + height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1), pixel_aspect: 1.0, } } @@ -620,7 +620,8 @@ impl Interpretation { ), }; it.invert_alpha = super::b_p(p, "invertAlpha"); - it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32); + // The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects. + it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32); it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0); it.fields = match str_p(p, "fields") { Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?), diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index f984d53f..1bd0d223 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result { let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len)); l.start_time = at - src_in; l.in_point = at; - l.out_point = (at + len).min(comp.duration); + // Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it. + l.out_point = at + len; let lid = l.id; let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; cm.layers.insert(0, l); @@ -206,8 +207,12 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); + let problem = match item.map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => s.footage.problem(f), + _ => None, + }; Ok( - json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}), + json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) } diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 3fda23c9..06278c2c 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -1,4 +1,4 @@ -//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app +//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app //! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the //! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI //! performs it. Headless sessions accept and ignore them. @@ -59,11 +59,6 @@ pub fn specs() -> Vec { // Viewer. fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp), fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp), - fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp), - fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp), - fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp), - fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp), - fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp), fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always), fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always), fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always), @@ -73,7 +68,9 @@ pub fn specs() -> Vec { fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always), fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp), fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always), - fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always), + // macOS uses Control+Command+F for native full screen. On Windows and Linux, + // Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp. + fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always), fe!( "view.newViewer", "New Viewer", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 1bfb3734..d4cf1d90 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -5,6 +5,7 @@ use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, + limits, }; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -92,8 +93,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] }); - let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width); - let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height); + // 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid. + let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width); + let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height); let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect); let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() }; let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name); @@ -114,7 +116,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result fn pixel_aspect_p(p: &Value) -> Result> { match p.get("pixelAspect").and_then(Value::as_f64) { Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")), - v => Ok(v), + v => Ok(v.map(limits::pixel_aspect)), } } @@ -295,7 +297,10 @@ fn add_item(s: &mut Session, p: &Value) -> Result { let mut l = build::layer(proj, &comp, &it.name, src, size, dur); l.start_time = start; l.in_point = start; - l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); + // Footage and precomps keep their full length past the comp's end (After Effects); items + // without a duration (solids, comp-length stills) end with the comp. + let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d); + l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration()); place(&mut l, position); insert_layer_at(proj, st, cid, l, index) })?; @@ -377,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string(); let v = b_p(p, "value"); let label = format!("Layer Switch ({name})"); + if b_p(p, "others") == Some(true) { + return set_switch_others(s, cid, &ids, &name, v, &label); + } // `merge`: a switch dragged over several layers in the Timeline is one undo step. let r = s.edit(&label, merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -394,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result { Ok(json!(r)) } +/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named +/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch +/// applies to is the opposite; when they already do, the other layers come back. `value` sets +/// the other layers directly. +fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option, label: &str) -> Result { + let r = s.edit(label, None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + // The state that sets a layer apart: unlocked for Lock, on for the rest. + let active = !matches!(name, "lock" | "locked"); + // Track mattes keep their video off by design. + let mattes: Vec = + if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() }; + let applies = |l: &Layer| match name { + "audio" => l.props.sub("audio").is_some(), + "lock" | "locked" => true, + _ => l.source.is_av() && !mattes.contains(&l.id), + }; + let mut alone = true; + for l in comp.layers.iter().filter(|l| applies(l)) { + let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; + alone &= on == (ids.contains(&l.id) == active); + } + let others = value.unwrap_or(alone == active); + for l in comp.layers.iter_mut().filter(|l| applies(l)) { + let v = if ids.contains(&l.id) { active } else { others }; + set_switch_value(l, name, v); + } + Ok(others) + })?; + Ok(json!(r)) +} + fn switch_value(l: &Layer, name: &str) -> Option { let sw = &l.switches; Some(match name { @@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result { let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); + // Relative trims: each layer moves its own in/out point by the same amount, so dragging an + // edge of several selected layers trims them all together (as in AE). + let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); + let comp_dur = comp.duration; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; @@ -891,6 +935,14 @@ fn timing(s: &mut Session, p: &Value) -> Result { if let Some(o) = outp { l.out_point = o.max(l.in_point + fd); } + // Stops at the comp start/end (a point already past it doesn't jump onto + // it), keeping at least one frame of layer. + if let Some(d) = in_delta { + l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd); + } + if let Some(d) = out_delta { + l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd); + } } } } @@ -1429,8 +1481,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result { _ => {} } let color = color_p(p, "color"); - let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32); - let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32); + let w = p.get("width").and_then(Value::as_u64).map(limits::size); + let h = p.get("height").and_then(Value::as_u64).map(limits::size); let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); @@ -1533,7 +1585,7 @@ pub fn specs() -> Vec { "Layer Switch", [], None, - "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}", + "{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}", has_layers, set_switch ), @@ -1555,7 +1607,7 @@ pub fn specs() -> Vec { "Layer Timing", [], None, - "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}", + "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}", has_layers, timing ), diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index ba00db38..755dd898 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result { /// Folder containing a file as a `file://` URL. pub(crate) fn folder_url(path: &str) -> String { let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(); - format!("file://{dir}") + file_url(&dir) +} + +/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash +/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces, +/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file. +pub(crate) fn file_url(path: &str) -> String { + use std::fmt::Write as _; + let path = path.replace('\\', "/"); + let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" }); + for b in path.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) { + url.push(char::from(b)); + } else { + let _ = write!(url, "%{b:02X}"); + } + } + url } fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { @@ -845,3 +862,21 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::{file_url, folder_url}; + + #[test] + fn file_urls_are_well_formed() { + assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov"); + assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov"); + assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov"); + assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay"); + } + + #[test] + fn folder_url_names_the_containing_folder() { + assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips"); + } +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 592a420c..99b2d642 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -76,6 +76,8 @@ pub(crate) mod watch_folder; pub(crate) use app_more::report_text as report_text_for_tests; pub(crate) use app_more::view_command; #[cfg(test)] +pub(crate) use file::open as open_for_tests; +#[cfg(test)] pub(crate) use keys_more::amplitudes as amplitudes_for_tests; #[cfg(test)] pub(crate) use mask::split_segment as split_segment_for_tests; @@ -99,6 +101,8 @@ pub struct CommandSpec { pub menu: &'static [&'static str], /// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere). pub shortcut: Option<&'static str>, + /// Further default shortcuts (the menus and tooltips show `shortcut`). + pub alt_shortcuts: &'static [&'static str], /// Parameter description for agents and the MCP schema. pub params: &'static str, pub enabled: Enabled, @@ -109,8 +113,22 @@ pub struct CommandSpec { #[macro_export] macro_rules! cmd { + // Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`. + ($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => { + $crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) } + }; ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + $crate::commands::CommandSpec { + id: $id, + label: $label, + menu: &[$($m),*], + shortcut: $sc, + alt_shortcuts: &[], + params: $params, + enabled: $en, + run: $run, + journal: true, + } }; } #[macro_export] @@ -121,6 +139,7 @@ macro_rules! query { label: $label, menu: &[], shortcut: None, + alt_shortcuts: &[], params: $params, enabled: $crate::commands::always, run: $run, @@ -449,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> { str_p(p, "merge") } -/// `comp`: item id or name; default the active comp. +/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP +/// clients often send numbers as strings); default the active comp. pub(crate) fn comp_id(s: &Session, p: &Value) -> Result { let missing = |v: &Value| EngineError::NoSuchComp(v.to_string()); match p.get("comp") { Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)), - Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)), + Some(v @ Value::String(name)) => s + .project + .items + .values() + .find(|i| &i.name == name && i.as_comp().is_some()) + .map(|i| i.id) + .or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some())) + .ok_or_else(|| missing(v)), _ => s.active_comp_id().ok_or(EngineError::NoComp), } } -/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. +/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`. +fn digits_id(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string +/// that names no layer is resolved as the number it spells. pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { match v { Value::Number(n) => { @@ -470,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().ok()) { return comp.layers.get(i.checked_sub(1)?).map(|l| l.id); } - comp.layers.iter().find(|l| &l.name == s).map(|l| l.id) + comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id)))) } _ => None, } diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 56139b6c..c755bda9 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } + // Footage that shows no picture although its file is there (a codec or profile the + // decoders lack): why, so an agent can tell the user. + if let ItemKind::Footage(f) = &i.kind + && let Some(why) = s.footage.problem(f) + { + o["problem"] = json!(why); + } o }) .collect(); @@ -80,6 +87,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result { "id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(), "workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background, "preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution, + "resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(), "layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::>(), })) } diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index b868828d..cef0abd0 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run}; #[allow(unused_macros)] macro_rules! stub { ($id:literal, $label:literal, [$($m:literal),*], $params:literal) => { - CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true } + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true } }; ($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => { - CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true } + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true } }; } diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 132e648d..228d4842 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result { if added == 0 && !kinds.is_empty() { return Err(bad(C, "unknown animator property")); } + // Like After Effects, a scripted animator has no selectors until the script adds one + // (addProperty("ADBE Text Selector")); a default 0-100% one would select every character. + if kinds.is_empty() + && let Some(sels) = g.sub_mut("selectors") + { + sels.children.clear(); + } let uid = g.uid; anims.children.push(g.into()); proj.next_id = next; diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index f9e2dd5e..e143690d 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result { Ok(switch_view(s, cid, v)) } +/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View), +/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project. +fn set_pane_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let v = view_p(p, "view.setPaneView")?; + let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize; + if pane == 0 { + switch_view(s, cid, v); + } else { + views(s, cid).set_pane(pane - 1, v); + } + let vs = views(s, cid); + Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]})) +} + macro_rules! view_cmd { ($f:ident, $v:expr) => { fn $f(s: &mut Session, p: &Value) -> Result { @@ -706,6 +721,15 @@ pub fn specs() -> Vec { has_comp, set_3d_view ), + cmd!( + "view.setPaneView", + "Set View of Pane", + [], + None, + "{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", + has_comp, + set_pane_view + ), cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active), cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default), cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index f98ea5ed..1bc4638d 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -39,7 +39,10 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } None => { - let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?; + // The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop + // too; only explicit `;` fields mean drop-frame (#890). + let drop_frame = fr.supports_drop_frame() && text.contains(';'); + let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?; let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); if relative { Ok(parsed) @@ -61,8 +64,9 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> fn set(s: &mut Session, p: &Value) -> Result { let comp = s.active_comp().ok_or(EngineError::NoComp)?; - let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) { - comp.frame_rate.tick_of(f) + // Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded. + let t = if let Some(f) = f_p(p, "frame") { + comp.frame_rate.tick_of(f.round() as i64) } else if let Some(tc) = str_p(p, "timecode") { let cur = comp.frame_rate.frame_at(s.time()); let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?; @@ -169,10 +173,10 @@ fn with_to(p: &Value, to: &str) -> Value { pub fn specs() -> Vec { vec![ cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set), - cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))), - cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))), - cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), - cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), + cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))), + cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))), + cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), + cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step), cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index 4cb001f6..abf92416 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -1,7 +1,7 @@ -//! View menu commands that change the project (guides) or headless-relevant editor state (the -//! region of interest). +//! View menu commands that change the project (guides, each comp's Resolution) or +//! headless-relevant editor state (the region of interest). -use effectcraft_project::Guide; +use effectcraft_project::{Guide, Resolution}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p}; @@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.region_of_interest)) } +/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved +/// with the project but no undo step ([`Session::set_resolution`]). +fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result { + let cid = comp_id(s, p)?; + s.set_resolution(cid, r)?; + Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()})) +} + +/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks +/// for it (the Custom Resolution dialog). +fn custom_resolution(s: &mut Session, p: &Value) -> Result { + match f_p(p, "factor") { + Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)), + None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")), + None => super::frontend(s, "view.res.custom", p), + } +} + fn view_layout(s: &mut Session, p: &Value) -> Result { let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?; if !matches!(n, 1 | 2 | 4) { @@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result { Ok(json!(v)) } +/// A `view.res.*` command setting one [`Resolution`]. +macro_rules! res { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => { + cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r)) + }; +} + pub fn specs() -> Vec { vec![ cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide), cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides), cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides), cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides), + res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full), + res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half), + res!("view.res.third", "Third", ["View", "Resolution"], None, Third), + res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter), + cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution), + // The Composition panel's Resolution popup (not in the View menu). + res!("view.res.auto", "Resolution: Auto", [], None, Auto), cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options), cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer), diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 6d905a83..8b178a05 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -341,7 +341,8 @@ impl Session { let k = new_line.iter().position(|&i| i == target).unwrap_or(0); let undo: Vec<(String, Arc)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect(); let redo: Vec<(String, Arc)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect(); - self.project = a.state[target].clone(); + let cur = std::mem::replace(&mut self.project, a.state[target].clone()); + self.keep_view_settings(&cur); self.history.undo = undo; self.history.redo = redo; self.history.branches = branches; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 87450a64..a6243e44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -707,9 +707,38 @@ impl Session { self.events.push(Event::ProjectChanged { revision: self.revision }); } + /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes + /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay + /// valid ([`effectcraft_project::Comp::same_pixels`]). + pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> { + if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r { + return Ok(()); + } + Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r; + self.bump(); + Ok(()) + } + + /// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the + /// project before undo, redo or a history jump restored another state. + pub(crate) fn keep_view_settings(&mut self, from: &Project) { + let keep: Vec<(ItemId, effectcraft_project::Resolution)> = + self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect(); + if keep.is_empty() { + return; + } + let p = Arc::make_mut(&mut self.project); + for (id, r) in keep { + if let Some(c) = p.comp_mut(id) { + c.resolution = r; + } + } + } + pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); self.history.redo.push((label, cur)); self.history.merge_key = None; self.sanitize_state(); @@ -720,6 +749,7 @@ impl Session { pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; let cur = std::mem::replace(&mut self.project, p); + self.keep_view_settings(&cur); self.history.undo.push((label, cur)); self.history.merge_key = None; self.sanitize_state(); @@ -859,6 +889,26 @@ impl Session { self.events.push(Event::OpenComp(id)); } + /// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]). + pub fn saved_view(&self) -> effectcraft_project::SavedView { + effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() } + } + + /// Reopen the compositions a project file was saved with (#748). Ids that no longer name a + /// comp are skipped; with none left the project stays on the comp [`Session::replace_project`] + /// opened (its first), as files from before the view was saved do. + pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) { + let mut open: Vec = Vec::new(); + for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) { + if !open.contains(&id) { + open.push(id); + } + } + let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return }; + self.state.open_comps = open; + self.open_comp(active); + } + pub fn toast(&mut self, msg: impl Into) { self.events.push(Event::Toast { message: msg.into(), error: false }); } @@ -1080,10 +1130,14 @@ mod tests_fidelity; #[cfg(test)] mod tests_frame_export; #[cfg(test)] +mod tests_fuzz_open; +#[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] mod tests_keylight; #[cfg(test)] +mod tests_limits; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_m137; diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index f5750372..2c89466a 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -1,25 +1,47 @@ -//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends -//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder), -//! and always keeps the last few hundred warnings and errors in memory for the System -//! Compatibility Report. +//! The `log` logger. Records go to standard error and, on the desktop, to a session log that +//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs +//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that +//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no +//! terminal, so that file is what a bug report can attach. The last few hundred warnings and +//! errors are also kept in memory for the System Compatibility Report. //! -//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging` -//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target: -//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that -//! fails before the window opens leaves a trace. +//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target: +//! `effectcraft=debug,wgpu=warn`). +//! +//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends +//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches. +//! +//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the +//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and +//! written first. Writing never panics: a file that can't be created or written leaves standard +//! error as the only sink. Never log the control-channel token. use std::collections::VecDeque; +use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Mutex, OnceLock, PoisonError}; use log::LevelFilter; +/// The session log's name inside the `Logs` folder. +pub const SESSION_LOG: &str = "effectcraft.log"; +/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft..log`). +pub const KEEP: usize = 2; +/// The session log stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the session log is attached. +pub const MAX_PENDING: usize = 512; +/// The filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,effectcraft=info"; + static ENABLED: AtomicBool = AtomicBool::new(false); -/// The `RUST_LOG` filter for stderr: (target prefix or every target, level). -static STDERR: OnceLock, LevelFilter)>> = OnceLock::new(); +/// The filter for standard error and the session log: (target prefix or every target, level). +static FILTER: OnceLock, LevelFilter)>> = OnceLock::new(); static STATE: Mutex = Mutex::new(State { file: None, recent: VecDeque::new() }); +static SESSION: Mutex = Mutex::new(Sink::new(MAX_FILE_BYTES)); struct State { file: Option, @@ -32,15 +54,16 @@ const RECENT: usize = 300; impl log::Log for Logger { fn enabled(&self, m: &log::Metadata) -> bool { - m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m) + m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m) } fn log(&self, r: &log::Record) { if !self.enabled(r.metadata()) { return; } - let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args()); - if to_stderr(r.metadata()) { - eprintln!("{line}"); + let line = format_line(×tamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string()); + if shown(r.metadata()) { + let _ = writeln!(std::io::stderr(), "{line}"); + SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n")); } let Ok(mut st) = STATE.lock() else { return }; if r.level() <= log::Level::Warn { @@ -59,22 +82,62 @@ impl log::Log for Logger { fn flush(&self) {} } -fn unix_secs() -> u64 { - web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +fn unix_millis() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0) +} + +fn thread_name() -> String { + #[cfg(not(target_arch = "wasm32"))] + return std::thread::current().name().unwrap_or("?").to_string(); + #[cfg(target_arch = "wasm32")] + return "main".to_string(); +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. +fn timestamp(ms: u64) -> String { + let secs = ms / 1000; + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most +/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + u64::from(m <= 2); + (y, m, d) +} + +/// One log line (without the newline): ` [] : `. +fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}") } /// Install the logger (once; later calls and other installed loggers are left alone). pub fn install() { static LOGGER: Logger = Logger; if log::set_logger(&LOGGER).is_ok() { - let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default()); - log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max)); + let filter = FILTER.get_or_init(|| { + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER)) + }); + log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max)); } } -/// Whether `RUST_LOG` lets a record through to stderr. -fn to_stderr(m: &log::Metadata) -> bool { - STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target())) +/// Whether the filter lets a record through to standard error and the session log. +fn shown(m: &log::Metadata) -> bool { + FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target())) } /// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target @@ -110,9 +173,112 @@ pub fn install_panic_hook() { })); } -/// The log file inside `config_dir` (the settings folder), or the temporary folder. +/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder. +pub fn log_dir(config_dir: Option<&Path>) -> PathBuf { + config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs") +} + +/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder. pub fn log_path(config_dir: Option<&Path>) -> PathBuf { - config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt") + log_dir(config_dir).join("EffectCraft Log.txt") +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) } +} + +/// Shift the previous session logs up one (`.log` → `.1.log` → … → `..log`, the oldest +/// dropped) and create a fresh, empty log file in `dir` (created if missing). +fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to +/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. +pub fn attach_session_log(dir: &Path) -> Result { + let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner); + sink.attach(file); + sink.path = Some(path.clone()); + Ok(path) +} + +/// This run's session log (once [`attach_session_log`] succeeded). +pub fn session_file() -> Option { + SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone() +} + +/// Where lines go besides standard error: kept in memory until a file is attached, then +/// appended to it until it reaches its size cap. +struct Sink { + file: Option, + path: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + const fn new(max_bytes: u64) -> Sink { + Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + fn write(&mut self, line: &str) { + if self.file.is_none() && !self.capped { + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } } /// Turn file logging on (writing to `file`) or off. @@ -123,7 +289,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> { std::fs::create_dir_all(d)?; } let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?; - writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?; + writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?; } if let Ok(mut st) = STATE.lock() { st.file = Some(file); @@ -136,7 +302,7 @@ pub fn is_enabled() -> bool { ENABLED.load(Ordering::Relaxed) } -/// The current log file (set once logging was enabled). +/// The current Enable Logging file (set once logging was enabled). pub fn current_file() -> Option { STATE.lock().ok().and_then(|s| s.file.clone()) } @@ -148,7 +314,8 @@ pub fn recent() -> Vec { #[cfg(test)] mod tests { - use super::{LevelFilter, level_for, parse_filter}; + use super::*; + use std::sync::atomic::AtomicUsize; /// `RUST_LOG` picks what start-up prints to stderr (#234). #[test] @@ -162,4 +329,111 @@ mod tests { assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off); assert!(parse_filter("").is_empty()); } + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap() + } + + /// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown. + #[test] + fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() { + let f = parse_filter(DEFAULT_FILTER); + assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info); + assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info); + assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn); + assert_eq!(level_for(&f, "naga"), LevelFilter::Warn); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z"); + assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z"); + let _ = timestamp(u64::MAX); + } + + /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).unwrap(); + assert_eq!(path, dir.join(SESSION_LOG)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).unwrap(); + } + assert_eq!(read(&dir.join(SESSION_LOG)), "run 4"); + assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2"); + assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::write(&dir, "a file where the directory should be").unwrap(); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).unwrap(); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).unwrap(); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).unwrap(); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"), + "2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full" + ); + } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 5acf5250..bd1cffb0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { } "layer.markersLock" => Some(layer?.markers_locked), "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), + "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => { + Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res."))) + } "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), "view.extendedViewer" => Some(s.prefs.three_d.extended_viewer), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a853cfc2..3abf336b 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -32,6 +32,10 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Українська", "uk"), ("Español", "es"), ("Português (Brasil)", "pt-br"), + ("Русский", "ru"), + ("Français", "fr"), + ("Deutsch", "de"), + ("Čeština", "cs"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). @@ -172,6 +176,8 @@ page!(Appearance { dark_theme: String = "dark".into(), /// The theme used in light appearance: `light`. light_theme: String = "light".into(), + /// Theme shortcuts in the appearance context menu. + favorite_themes: Vec = Vec::new(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, /// The size of the whole interface in percent (75–200), on top of the display's own scale. @@ -332,6 +338,42 @@ pub struct LabelPref { pub color: String, } +/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the +/// frame size, pixel aspect and frame rate, as in After Effects' own presets. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompPreset { + pub name: String, + pub width: u32, + pub height: u32, + pub pixel_aspect: f64, + pub frame_rate: f64, +} + +impl CompPreset { + /// Longest preset name kept. + pub const MAX_NAME: usize = 128; + + /// Values a composition accepts (a hand-edited settings file can hold anything). + pub fn is_valid(&self) -> bool { + let size = 4..=30000; + !self.name.trim().is_empty() + && self.name.chars().count() <= Self::MAX_NAME + && size.contains(&self.width) + && size.contains(&self.height) + && self.pixel_aspect.is_finite() + && (0.01..=100.0).contains(&self.pixel_aspect) + && self.frame_rate.is_finite() + && (1.0..=999.0).contains(&self.frame_rate) + } +} + +/// Saved composition presets, skipping entries that don't parse instead of losing the list. +fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Vec::::deserialize(d)?; + Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect()) +} + fn default_labels() -> Vec { Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect() } @@ -390,6 +432,9 @@ pub struct Prefs { pub preview: crate::preview::PreviewSettings, /// View ▸ Simulate Output ▸ My Custom RGB. pub custom_rgb: crate::viewer::CustomRgb, + /// Composition Settings presets the user saved, in the order saved. + #[serde(deserialize_with = "lenient_comp_presets")] + pub comp_presets: Vec, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -425,6 +470,7 @@ impl Default for Prefs { recent_fonts: vec![], preview: crate::preview::PreviewSettings::default(), custom_rgb: crate::viewer::CustomRgb::default(), + comp_presets: vec![], extra: Extra::new(), } } @@ -550,6 +596,7 @@ impl Prefs { /// Clamp values into their valid ranges and fill missing labels. pub fn normalize(&mut self) { self.version = PREFS_VERSION; + self.comp_presets.retain(CompPreset::is_valid); if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) { self.startup.window_graphics = Startup::default().window_graphics; } @@ -564,6 +611,8 @@ impl Prefs { a.interval_minutes = a.interval_minutes.clamp(1, 240); a.max_versions = a.max_versions.clamp(1, 99); let ap = &mut self.appearance; + let mut seen = std::collections::BTreeSet::new(); + ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone())); if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { ap.appearance_mode = Appearance::default().appearance_mode; } @@ -620,6 +669,23 @@ impl Prefs { let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); return Err(format!("`{key}` expects one of {}", all.join(", "))); } + let favorites = if key == "appearance.favoriteThemes" { + Some(&value) + } else if key == "appearance" { + value.get("favoriteThemes") + } else { + None + }; + if let Some(favorites) = favorites { + let values = favorites.as_array().ok_or("favorite themes must be a list")?; + let mut seen = std::collections::BTreeSet::new(); + for value in values { + let id = value.as_str().ok_or("favorite theme must be a string")?; + if !is_choice(THEMES, id) || !seen.insert(id) { + return Err("unknown or duplicate favorite theme".into()); + } + } + } // Older clients set the single theme (by key, or inside the page object): it still picks // a visible theme, fixing the mode to its family. let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); @@ -639,8 +705,8 @@ impl Prefs { Ok(()) } - /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is - /// kept. + /// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and + /// the saved composition presets are kept. pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { let d = Prefs::default(); let Some(page) = page else { @@ -650,6 +716,7 @@ impl Prefs { recent_presets: std::mem::take(&mut self.recent_presets), recent_fonts: std::mem::take(&mut self.recent_fonts), preview: std::mem::take(&mut self.preview), + comp_presets: std::mem::take(&mut self.comp_presets), ..d }; return Ok(()); diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index efd63a9d..b025a7a5 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo } fn new_comp(cx: &Ctx) -> Comp { - Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration) + // A PSB can be 300,000 px a side; a composition is at most 30,000 (#788). + let size = |v: u32| effectcraft_project::limits::size(v.into()); + Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration) } fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option) -> ItemId { diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index cd628b9f..942184e3 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { let entries = crate::menus::entries(); for (path, e) in &entries { let key = binding_key(&e.command, &e.params); - let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect(); + // A plain entry also gets its command's further default keys. + let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] }; + let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect(); push( Bindable { key, @@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) { continue; } - let defaults = c.shortcut.and_then(normalize).into_iter().collect(); + let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect(); push( Bindable { key: c.id.to_string(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b0d61ec..72133c5d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -628,3 +628,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() { s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap(); assert_eq!(s.active_comp().unwrap().layers.len(), 2); } + +/// The active comp and the open comp tabs are saved with the project and restored on open (#748). +#[test] +fn reopening_a_project_restores_its_active_comp() { + use effectcraft_project::ItemId; + let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("view.ecproj").to_string_lossy().to_string(); + let mut s = Session::default(); + let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap(); + let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap(); + let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.open", json!({"comp": "CompB"})).unwrap(); + s.state.open_comps.retain(|id| id.0 != c); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(s2.state.active_comp, Some(ItemId(b))); + assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]); + assert!(!s2.is_dirty()); + + // A file from before the view was saved opens on its first comp, as it always did. + let text = std::fs::read_to_string(&path).unwrap(); + let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); + let obj = old.as_object_mut().unwrap(); + obj.remove("activeComp"); + obj.remove("openComps"); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s3 = Session::default(); + s3.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); + + // Ids that no longer name a comp (edited by hand, or written by another tool) fall back. + obj_set(&mut old, json!(999), json!([999, b, "x"])); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s4 = Session::default(); + s4.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)])); + obj_set(&mut old, json!(999), json!([])); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s5 = Session::default(); + s5.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); + + // XML copies carry it too. + let xml = dir.join("view.ecprojx").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": xml})).unwrap(); + let mut s6 = Session::default(); + s6.execute("file.open", json!({"path": xml})).unwrap(); + assert_eq!(s6.state.active_comp, Some(ItemId(b))); + let _ = std::fs::remove_dir_all(dir); + + fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) { + let o = v.as_object_mut().unwrap(); + o.insert("activeComp".into(), active); + o.insert("openComps".into(), open); + } +} diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 955a8f16..cf9773f9 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -243,3 +243,25 @@ fn demo_has_3d_showcase() { let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count(); assert!(lit > 2_000, "{lit}"); } + +/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and +/// editor states saved before panes existed load with the default Top / Front / Right. +#[test] +fn layout_panes_choose_their_view() { + use effectcraft_render::three_d::{View3D, Views3D}; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top); + let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap(); + assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"])); + assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched"); + s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap(); + assert_eq!(s.state.views3d[&cid].current, View3D::Custom1); + assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err()); + assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err()); + let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap(); + assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom); + let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap(); + assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right)); +} diff --git a/crates/engine/src/tests_fuzz_open.rs b/crates/engine/src/tests_fuzz_open.rs new file mode 100644 index 00000000..5ccd84cf --- /dev/null +++ b/crates/engine/src/tests_fuzz_open.rs @@ -0,0 +1,342 @@ +//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an +//! error, never a panic, an abort or a hang (AGENTS.md "Never crash"). +//! +//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one +//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips, +//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON +//! node replaced by an extreme number, another type or another subtree; members dropped, +//! elements duplicated), then opened through the real `file.open` path, followed by the queries +//! the app and agents run on a fresh project and a thumbnail-size render of the first frame. +//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang +//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack +//! overflow can't be caught and aborts the run, which fails it all the same. +//! +//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run: +//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_` +//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration). +//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::{Services, Session}; + +const PATH: &str = "/fuzz/input.ecproj"; + +/// splitmix64: tiny, good enough for picking mutations. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// `0..n` (`0` when `n` is 0). + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn chance(&mut self, one_in: usize) -> bool { + self.below(one_in) == 0 + } +} + +/// One file, served from memory; everything else is missing. +struct OneFile(Vec); + +impl Services for OneFile { + fn read_file(&self, path: &str) -> std::io::Result> { + if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) } + } + fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other("the fuzzer writes nothing")) + } + fn exists(&self, path: &str) -> bool { + path == PATH + } + fn stamp(&self, _: &str) -> Option<(u64, u128)> { + None + } + fn list_dir(&self, _: &str) -> Vec { + vec![] + } + fn is_dir(&self, _: &str) -> bool { + false + } +} + +/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when +/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open +/// itself, or one the command guard caught in a query. +fn open_input(bytes: Vec) -> Result { + let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() }; + if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() { + return Ok(false); // refused: fine + } + let crashed = |r: crate::Result| match r { + Err(e) if e.to_string().contains("internal error") => Err(e.to_string()), + _ => Ok(()), + }; + crashed(s.execute("project.summary", json!({})))?; + let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::>())).collect(); + for (comp, layers) in comps.into_iter().take(4) { + crashed(s.execute("comp.info", json!({ "comp": comp })))?; + for layer in layers.into_iter().take(8) { + crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?; + } + } + // The first frame the viewer shows, at thumbnail size. + if let Some(id) = s.state.active_comp + && let Some(c) = s.project.comp(id) + { + let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0); + effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale); + } + Ok(true) +} + +/// Run one input on its own thread: a panic becomes its message, a hang a timeout. +fn run_guarded(bytes: Vec, deadline: Duration) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes))); + let r = match r { + Ok(r) => r, + Err(p) => { + Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default())) + } + }; + let _ = tx.send(r); + }); + if let Err(e) = spawned { + return Err(format!("couldn't start the fuzz thread: {e}")); + } + rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}"))) +} + +/// Small projects covering what a project holds: the empty project, and the demo cut down to +/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects, +/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole +/// demo (700 kB) would make every input slow. +fn seeds() -> Vec> { + let demo = crate::demo::demo_project(); + let mut projects = vec![effectcraft_project::Project::default()]; + for (cid, c) in demo.comps() { + for i in 0..c.layers.len() { + let mut p = demo.clone(); + for (id, item) in p.items.iter_mut() { + if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind { + let keep = if id == cid { i } else { 0 }; + let layers = &mut Arc::make_mut(c).layers; + *layers = layers.get(keep).cloned().into_iter().collect(); + } + } + projects.push(p); + } + } + let mut out = vec![]; + for p in projects { + let json = p.to_file_json().unwrap(); + out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes()); + out.push(json.into_bytes()); + } + out +} + +#[rustfmt::skip] +const TOKENS: &[&str] = &[ + "{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615", + "-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "", "", "", "", + "", "", "", "", "", "", "", "", "&", ";", "�", + "�", "&#;", "&#x;", "&", "\u{feff}", "é", "\u{1F600}", +]; + +const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0]; +const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993]; + +/// Every node of `v`, depth first (the root is 0). +fn count(v: &Value) -> usize { + 1 + match v { + Value::Array(a) => a.iter().map(count).sum(), + Value::Object(m) => m.values().map(count).sum(), + _ => 0, + } +} + +/// The `k`-th node (depth first; the root is 0). +fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> { + if *k == 0 { + return Some(v); + } + *k -= 1; + match v { + Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)), + Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)), + _ => None, + } +} + +fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> { + nth(v, &mut { k }) +} + +fn interesting(r: &mut Rng, around: &Value) -> Value { + match r.below(7) { + 0 => json!(NUMBERS[r.below(NUMBERS.len())]), + 1 => json!(INTEGERS[r.below(INTEGERS.len())]), + 2 => match around.as_f64() { + Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]), + None => Value::Null, + }, + 3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]), + 4 => Value::Bool(r.chance(2)), + 5 => json!([]), + _ => Value::Null, + } +} + +/// A structural mutation of a project's JSON (when the input still parses). +fn mutate_tree(r: &mut Rng, v: &mut Value) { + let n = count(v); + for _ in 0..1 + r.below(4) { + let k = r.below(n.max(1)); + let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null); + let Some(node) = node_mut(v, k) else { return }; + match r.below(6) { + 0 | 1 => *node = interesting(r, node), + 2 => *node = donor, + 3 => match node { + Value::Object(m) if !m.is_empty() => { + let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default(); + m.remove(&key); + } + Value::Array(a) if !a.is_empty() => { + a.remove(r.below(a.len())); + } + _ => *node = Value::Null, + }, + 4 => { + if let Value::Array(a) = node + && !a.is_empty() + { + let i = r.below(a.len()); + let x = a[i].clone(); + a.insert(r.below(a.len() + 1), x); + } + } + _ => { + if let Value::Object(m) = node { + m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null)); + } + } + } + } +} + +fn mutate_bytes(r: &mut Rng, b: &mut Vec) { + for _ in 0..1 + r.below(3) { + let len = b.len(); + match r.below(8) { + 0 if len > 0 => { + let i = r.below(len); + b[i] ^= 1 << r.below(8); + } + 1 if len > 0 => { + let i = r.below(len); + b[i] = r.next() as u8; + } + 2 if len > 0 => { + let a = r.below(len); + let e = (a + 1 + r.below(64)).min(len); + b.drain(a..e); + } + 3 if len > 0 => { + let a = r.below(len); + let e = (a + 1 + r.below(256)).min(len); + let chunk = b[a..e].to_vec(); + let at = r.below(len + 1); + b.splice(at..at, chunk); + } + 4 => { + let t = TOKENS[r.below(TOKENS.len())].as_bytes(); + let at = r.below(len + 1); + b.splice(at..at, t.iter().copied()); + } + 5 if len > 0 => b.truncate(r.below(len)), + 6 => { + // Deep nesting: what a recursive reader overflows its stack on. + let (open, close) = [("[", "]"), ("", ""), ("", "")][r.below(3)]; + let depth = [200, 5_000, 200_000][r.below(3)]; + // Where the reader expects a value: after an opening token of the same kind. + let opens: Vec = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect(); + let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] }; + let mut nest = open.repeat(depth).into_bytes(); + nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes()); + b.splice(at..at, nest); + } + _ => {} + } + } +} + +/// One fuzz input from the seeds, decided by the iteration's own generator. +fn mutate(r: &mut Rng, seeds: &[Vec]) -> Vec { + let mut b = seeds[r.below(seeds.len())].clone(); + if r.chance(2) { + let text = String::from_utf8_lossy(&b).to_string(); + let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) }; + if let Some(mut v) = json.and_then(|j| serde_json::from_str::(&j).ok()) { + mutate_tree(r, &mut v); + let j = v.to_string(); + b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() }; + } + } + if r.chance(3) || b.is_empty() { + mutate_bytes(r, &mut b); + } + b +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default) +} + +#[test] +fn fuzz_file_open_never_crashes() { + let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022); + let start = env_u64("EFFECTCRAFT_FUZZ_START", 0); + let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000); + let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6)); + let seeds = seeds(); + // The unmutated seeds open. + for s in &seeds { + assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true)); + } + let began = Instant::now(); + let mut failures = vec![]; + let (mut ran, mut opened) = (0, 0); + for i in start..start + iters { + if began.elapsed() > budget { + break; + } + let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93)); + let input = mutate(&mut r, &seeds); + ran += 1; + match run_guarded(input.clone(), Duration::from_secs(10)) { + Ok(o) => opened += usize::from(o), + Err(e) => { + let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string(); + failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len())); + if failures.len() >= 5 { + break; + } + } + } + } + assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n")); + eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed()); +} diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs new file mode 100644 index 00000000..68f2c04c --- /dev/null +++ b/crates/engine/src/tests_limits.rs @@ -0,0 +1,104 @@ +//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is +//! opened and when a command sets them (#788). + +use effectcraft_project::{ItemKind, limits}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::{Event, Session}; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +/// The saved project with `edit` applied to the JSON of every item of kind `kind`. +fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String { + let mut v: Value = serde_json::from_str(text).unwrap(); + let items = v["items"].as_object_mut().unwrap(); + for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) { + edit(&mut it["kind"]); + } + serde_json::to_string(&v).unwrap() +} + +/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur +/// samples, holding a solid of 4 billion px. +fn hostile_project() -> String { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap(); + let text = s.project.to_file_json().unwrap(); + let text = tamper(&text, "Comp", |c| { + c["width"] = json!(4_000_000_000u64); + c["height"] = json!(0); + c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1}); + c["duration"] = json!(-5); + c["motion_blur_samples"] = json!(4_000_000_000u64); + }); + tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64)) +} + +#[test] +fn a_hostile_project_opens_within_the_limits_and_says_what_changed() { + let json_path = tmp("Hostile.ecproj"); + let text = hostile_project(); + std::fs::write(&json_path, &text).unwrap(); + let xml_path = tmp("Hostile.ecprojx"); + std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap(); + for path in [json_path, xml_path] { + let mut s = Session::default(); + let r = s.execute("file.open", json!({"path": path})).unwrap(); + let repaired = r["repaired"].as_array().unwrap(); + assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}"); + let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits"))); + assert!(toast, "{path}"); + let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap(); + assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1)); + assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS); + assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame"); + assert_eq!(c.work_area, (Tick::ZERO, c.duration)); + assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1); + let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap(); + assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2)); + // The comp renders at Full resolution, at its (capped) size. + let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1)); + } +} + +#[test] +fn a_project_within_the_limits_opens_unchanged() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap(); + let text = s.project.to_file_json().unwrap(); + let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(p, *s.project); +} + +#[test] +fn commands_cap_sizes_at_30000_px() { + let mut s = Session::default(); + // 2^32 + 10 used to wrap to 10 px. + s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap(); + let c = s.active_comp().unwrap().clone(); + assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT)); + s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap(); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE)); + // New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid. + s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap(); + let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap(); + assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE)); + // Layer Settings on the new (selected) solid layer. + s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap(); + assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8)); + s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap(); + assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4)))); + // A placeholder can't ask for a huge frame or a zero frame rate either. + s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap(); + let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap(); + assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c7252acb..cb83c163 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -52,6 +52,41 @@ fn layer_quality_sampling_frame_blending_and_undo() { assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err()); } +/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the +/// other layers come back the next time. +#[test] +fn set_switch_others() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let c = solid(&mut s, "#0000ff"); + let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l))); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap(); + // B is hidden: it is shown, alone; then the others come back; one undo step each. + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [false, true, false]); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [false, true, false]); + // Solo. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]); + // Lock: the layer stays unlocked and the others lock, then unlock. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.locked), [false; 3]); + // A track matte keeps its video off. + s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [false, true, true]); + assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); +} + #[test] fn layer_switches_menu() { let mut s = comp(); @@ -1416,3 +1451,16 @@ fn duplicate_with_shape_items_selected_duplicates_them_in_place() { s.execute("edit.duplicate", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().layers.len(), n + 1); } + +/// The Interpret Footage form sends "Loop (times)" as a float (3.0); it must still be applied (#909). +#[test] +fn interpret_footage_loop_from_form_float() { + let mut s = comp(); + let p = s.execute("file.importPlaceholder", json!({"name": "Shot", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_project::ItemId(p)]; + s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap(); + match &s.project.item(effectcraft_project::ItemId(p)).unwrap().kind { + ItemKind::Footage(f) => assert_eq!(f.loop_count, 3), + _ => panic!(), + } +} diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b77c7e9b..b442d7c3 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -185,6 +185,16 @@ fn scopes_read_the_comp_frame() { // ---------------------------------------------------------------- Footage panel +#[test] +fn footage_panel_overlay_keeps_full_length_past_the_comp_end() { + let (mut s, cid, _clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0])); + s.execute("footage.open", json!({"item": item.0})).unwrap(); + let r = s.execute("footage.overlayEdit", json!({"item": item.0, "in": 0.0, "out": 4.0, "at": 6.0})).unwrap(); + let ov = LayerId(r["layer"].as_u64().unwrap()); + let l = s.project.comp(cid).unwrap().layer(ov).unwrap(); + assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (6.0, 10.0)); +} + #[test] fn footage_panel_overlay_and_ripple_insert() { let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0])); @@ -629,3 +639,36 @@ fn media_browser_over_a_virtual_tree() { assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]); assert!(s.execute("mediaBrowser.action", json!({})).is_err()); } + +/// Footage the source can't decode is reported with its reason by `project.summary` and +/// `footage.info`, so an agent can tell the user why the picture is black. +#[test] +fn footage_that_cannot_be_decoded_reports_why() { + struct Broken; + impl FootageSource for Broken { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + Some(format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(Broken), ..Default::default() }; + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap(); + let f = Footage { + kind: FootageKind::Video, + path: "a.mp4".into(), + codec: "H.264 (High 4:2:2)".into(), + width: 64, + height: 36, + has_video: true, + ..Default::default() + }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(f)); + let sum = s.execute("project.summary", json!({})).unwrap(); + let it = sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(item.0)).unwrap(); + assert_eq!(it["problem"], json!("H.264 (High 4:2:2) isn't supported by the built-in decoder")); + assert!(sum["items"].as_array().unwrap().iter().filter(|i| i["id"] != json!(item.0)).all(|i| i.get("problem").is_none()), "only footage reports one"); + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index f234bd7c..02c2a00b 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -212,6 +212,50 @@ fn prefs_commands_get_set_reset_and_persist() { ); } +/// Composition Settings presets the user saves live in the settings: they persist, a save with +/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited +/// settings file) are dropped on load. +#[test] +fn composition_presets_save_replace_delete_and_persist() { + let store = Arc::new(MemoryConfig::default()); + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps}); + assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false})); + s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap(); + assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true)); + let list = s.execute("comp.presets", json!({})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert_eq!(list[0]["name"], "Edit 1440p 60"); + assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0))); + assert_eq!(list[1]["width"], json!(1080), "replaced in place"); + for bad in [ + p("", 1920, 30.0), + p("Tiny", 2, 30.0), + p("Fast", 1920, 5000.0), + json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}), + json!({"name": "x"}), + ] { + assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}"); + } + // Persisted, and kept by Reset All. + let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; + s2.load_settings(); + assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets); + s2.execute("prefs.reset", json!({})).unwrap(); + assert_eq!(s2.prefs.comp_presets.len(), 2); + s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap(); + assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone"); + let mut s3 = Session { config: Some(store.clone()), ..Default::default() }; + s3.load_settings(); + assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); + // A hand-edited settings file: the broken preset is dropped, the good one kept. + let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap(); + v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN})); + v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7})); + let back = Prefs::from_json(&v.to_string()); + assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); +} + #[test] fn undo_levels_are_honoured() { let mut s = session_with_comp(); @@ -331,6 +375,24 @@ fn defaults_have_no_conflicts_and_match_the_menus() { assert!(t.bindables.len() > 500); } +/// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). +#[test] +fn cmd_arrows_step_frames_like_page_keys() { + let s = Session::default(); + let t = s.shortcuts(); + for (id, keys) in [ + ("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]), + ("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]), + ("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]), + ("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]), + ] { + assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}"); + // The menus and tooltips keep showing the Page key. + assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0])); + } + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); +} + #[test] fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() { let store = Arc::new(MemoryConfig::default()); @@ -587,7 +649,7 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br")); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let saved = store.read(PREFS_FILE).unwrap(); - for bad in [json!("fr"), json!(""), json!(17), json!(null)] { + for bad in [json!("xx"), json!(""), json!(17), json!(null)] { assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err()); assert_eq!(s.prefs.general.language, "ja"); assert_eq!(store.read(PREFS_FILE).unwrap(), saved); @@ -613,6 +675,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { let mut es = Session { config: Some(store.clone()), ..Default::default() }; es.load_settings(); assert_eq!(es.prefs.general.language, "es"); + // Czech uses the same validation, persistence and JSON round-trip as every catalog. + assert!(crate::prefs::LANGUAGES.contains(&("Čeština", "cs"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("cs")); + let mut cs = Session { config: Some(store.clone()), ..Default::default() }; + cs.load_settings(); + assert_eq!(cs.prefs.general.language, "cs"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 5794e477..e409fc66 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -420,3 +420,19 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err()); assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } + +/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches. +#[test] +fn numeric_string_resolves_as_id() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.resolve_comp(Some(&json!(cid.0.to_string()))).unwrap(), cid); + assert!(s.resolve_comp(Some(&json!("999999"))).is_err()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!(crate::commands::resolve_layer(comp, &json!(l.to_string())), Some(LayerId(l))); + assert_eq!(crate::commands::resolve_layer(comp, &json!("999999")), None); + let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(l), "{r}"); +} diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 29755612..009af9e5 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -414,6 +414,25 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); } +/// #890: at 29.97 / 59.94 fps the displayed (non-drop) timecode was read back as drop-frame, so +/// committing the current-time field moved the playhead to another frame from minute 1 on. +#[test] +fn time_set_round_trips_the_displayed_timecode_at_drop_frame_rates() { + for fps in [29.97, 59.94] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "frameRate": fps, "duration": 4000})).unwrap(); + for frame in [0_i64, 1799, 1800, 1801, 3600, 17982, 17983, 100_000] { + let r = s.execute("time.set", json!({"frame": frame})).unwrap(); + let display = r["display"].as_str().unwrap().to_string(); + let r = s.execute("time.set", json!({"timecode": display})).unwrap(); + assert_eq!(r["frame"], frame, "{fps} fps: displayed {display}"); + } + // Explicit drop-frame fields still mean drop-frame. + let r = s.execute("time.set", json!({"timecode": "0;01;00;02"})).unwrap(); + assert_eq!(r["frame"], if fps > 30.0 { 3598 } else { 1800 }); + } +} + #[test] fn time_set_keeps_the_frame_shown_in_the_current_time_field() { let mut s = Session::default(); @@ -712,6 +731,89 @@ fn deleting_time_remap_turns_time_remapping_off() { assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1); } +/// U (keyframes), the animation reveal and UU (modified) take in Time Remap, which sits at the +/// top of the layer's tree rather than in a group. +#[test] +fn reveal_shortcuts_take_in_time_remap() { + let (mut s, l) = precomp_setup(); + let revealed = |s: &mut Session, kind: &str| -> Vec { + let r = s.execute("anim.reveal", json!({"kind": kind})).unwrap(); + r["props"].as_array().unwrap().iter().filter(|p| p["layer"] == l).filter_map(|p| p["prop"].as_u64()).collect() + }; + for kind in ["keyframes", "animation", "modified"] { + assert!(revealed(&mut s, kind).is_empty(), "{kind}: nothing to reveal before remapping"); + } + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let uid = prop(&s, l, "timeRemap").uid; + for kind in ["keyframes", "animation", "modified"] { + assert_eq!(revealed(&mut s, kind), vec![uid], "{kind}"); + } +} + +/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text +/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre. +#[test] +fn modified_reveal_is_empty_for_untouched_layers() { + let (mut s, solid) = setup(); + let mut layers = vec![("solid", solid)]; + for (cmd, params) in [ + ("layer.newText", json!({"text": "Hi"})), + ("layer.newShape", json!({})), + ("layer.newNull", json!({})), + ("layer.newCamera", json!({})), + ("layer.newLight", json!({})), + ("layer.newLight", json!({"type": "ambient"})), + ] { + let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}")); + layers.push((cmd, r["layer"].as_u64().unwrap())); + } + s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap(); + for (what, l) in layers { + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + let paths: Vec = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect(); + assert!(paths.is_empty(), "{what}: {paths:?}"); + } +} + +/// Match-id path of property or group `uid` of `layer`. +fn path_of(s: &Session, layer: u64, uid: u64) -> String { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap(); + let mut out = String::new(); + for n in l.props.node_chain(uid).unwrap_or_default() { + out.push('/'); + out.push_str(n.match_id()); + } + out +} + +/// UU takes in static changes outside Transform: Material, Camera and Light Options, a +/// separated position, and layer styles in full. +#[test] +fn modified_reveal_takes_in_options_and_layer_styles() { + let (mut s, solid) = setup(); + let modified = |s: &mut Session, l: u64| -> Vec { + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap(); + r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect() + }; + s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap(); + assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]); + s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap(); + assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]); + s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap(); + assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid)); + + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap(); + assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]); + let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap(); + assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]); +} + /// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts /// the end key at the source's end, not at the extended out point (#290). #[test] @@ -946,3 +1048,75 @@ fn proportional_share_is_linear_in_selection_order() { assert_eq!(proportional_share(0, 0), 1.0); assert_eq!(proportional_share(usize::MAX, 3), 1.0); } + +#[test] +fn add_item_keeps_footage_longer_than_the_comp_at_full_length() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); + let f = effectcraft_project::Footage { + width: 100, + height: 100, + kind: effectcraft_project::FootageKind::Video, + has_video: true, + duration: effectcraft_time::Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("long.mp4", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)); + let l = s.execute("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap(); + assert_eq!(layer.out_point.seconds(), 10.0); +} + +/// View > Go to Time's dialog sends its Frame field as a float; `time.set` used to reject it +/// with "need `time`, `frame` or `timecode`". +#[test] +fn go_to_time_accepts_a_float_frame_from_the_dialog() { + let (mut s, _) = setup(); + let r = s.execute("time.set", json!({"frame": 12.0})).unwrap(); + assert_eq!(r["frame"], json!(12)); + let r = s.execute("time.set", json!({"frame": 7})).unwrap(); + assert_eq!(r["frame"], json!(7)); +} + +/// Dragging a trim edge with several layers selected trims every selected layer by the same +/// amount from its own in/out point (as in AE), clamped to the comp and to one frame of layer; +/// a whole merged drag is one undo step. +#[test] +fn relative_trim_moves_every_selected_layer_by_the_same_delta() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [a], "in": 0.5, "out": 2.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [b], "in": 1.0, "out": 3.5})).unwrap(); + let io = |s: &Session, l: u64| { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap(); + (l.in_point.seconds(), l.out_point.seconds()) + }; + let near = |x: f64, y: f64| (x - y).abs() < 1e-9; + let undo_len = s.history.undo.len(); + // In-point drag in frame steps, merged into one edit. + for _ in 0..3 { + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 0.1, "merge": "bar-trim"})).unwrap(); + } + assert_eq!(s.history.undo.len(), undo_len + 1); + let ((ai, ao), (bi, bo)) = (io(&s, a), io(&s, b)); + assert!(near(ai, 0.8) && near(ao, 2.0) && near(bi, 1.3) && near(bo, 3.5), "{ai} {ao} {bi} {bo}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(near(io(&s, a).0, 0.5) && near(io(&s, b).0, 1.0)); + s.history.merge_key = None; + // Out-point drag: b hits the comp end (4 s) while a keeps going. + s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 1.0})).unwrap(); + assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b)); + // In-point drag left: a stops at the comp start. + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -0.8})).unwrap(); + assert!(near(io(&s, a).0, 0.0) && near(io(&s, b).0, 0.2), "{:?} {:?}", io(&s, a), io(&s, b)); + // Trimming past the other edge keeps one frame of layer. + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 10.0})).unwrap(); + let fd = 1.0 / 30.0; + assert!(near(io(&s, a).0, 3.0 - fd) && near(io(&s, b).0, 4.0 - fd), "{:?} {:?}", io(&s, a), io(&s, b)); + s.execute("layer.timing", json!({"layers": [a, b], "outDelta": -10.0})).unwrap(); + assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b)); + // A point already outside the comp trims from where it is instead of jumping onto the edge. + s.execute("layer.timing", json!({"layers": [a], "in": -1.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); + assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); +} diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index a6139668..be3edf46 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -574,3 +574,66 @@ fn graph_transform_box_scales_key_times_and_values() { assert_eq!(p.keys[1].value.as_f64(), 50.0); assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err()); } + +// ---------------------------------------------------------------- resolution + +#[test] +fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() { + use effectcraft_project::Resolution; + let mut s = comp(); + let a = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap(); + let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id())); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); + let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap()); + let r = s.execute_checked("view.res.half", json!({})).unwrap(); + assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2))); + // No undo step, but the project is modified (it's saved with it). + assert_eq!(s.history.undo.len(), steps); + assert!(s.is_dirty()); + assert!(s.active_comp().unwrap().same_pixels(&pixels)); + // Another comp has its own; switching back shows comp A's again. + s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap(); + let b = s.active_comp_id().unwrap(); + assert_ne!(a, b); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); + s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap(); + s.open_comp(a); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half); + assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half")); + assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true)); + assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false)); + // Undo and redo restore the comps, not their resolutions. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(b).is_none(), "undid New Composition"); + assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid"); + assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + // The comp's explicit `comp` parameter; the same value again changes nothing. + s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap(); + let rev = s.revision; + s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap(); + assert_eq!(s.revision, rev); + // Saved with the project and read back. + s.execute("file.save", json!({})).unwrap(); + assert!(!s.is_dirty()); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half); + assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter); + let _ = std::fs::remove_file(path); + // Custom without a factor asks the frontend (the Custom Resolution dialog). + s.drain_events(); + s.execute_checked("view.res.custom", json!({})).unwrap(); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom"))); + assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err()); + s.execute_checked("view.res.auto", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index b9758668..5abb942a 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -3,13 +3,15 @@ //! //! Nothing here touches the project; edits go through commands. -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; use effectcraft_color::{ColorSpace, space}; use effectcraft_geom::{Mat3, vec2}; -use effectcraft_project::{Layer, LayerId}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Layer, LayerId, Property}; use effectcraft_raster::Image; -use effectcraft_render::EvalCtx; +use effectcraft_render::{EvalCtx, ExprHost}; use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; @@ -274,10 +276,45 @@ pub struct SnapOptions { pub grid_spacing: f64, } +type SnapValues = HashMap<(u64, u64, u64, i64), Result>; + +/// Bounds, vertices and transforms can ask for the same expression repeatedly. Share its +/// value only within this immutable snap query; a new pointer update starts a fresh cache. +struct SnapExpressions<'a> { + inner: &'a dyn ExprHost, + values: Mutex, +} + +impl ExprHost for SnapExpressions<'_> { + fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result { + let key = (ctx.comp_id.0, layer.id.0, prop.uid, ctx.time.0); + if let Some(value) = self.values.lock().unwrap_or_else(PoisonError::into_inner).get(&key).cloned() { + return value; + } + // Never hold the lock while evaluating: expressions may query other properties. + let result = self.inner.eval(ctx, layer, prop, value); + let mut values = self.values.lock().unwrap_or_else(PoisonError::into_inner); + if values.len() < 8192 { + values.insert(key, result.clone()); + } + result + } + + fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> { + self.inner.eval_text_selector(ctx, layer, prop, index, total, selector) + } +} + /// Snap targets of a comp at the context time: other visible layers' features and vertices and /// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and /// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped. pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec { + let memo = ctx.expr.map(|inner| SnapExpressions { inner, values: Mutex::default() }); + let ctx = EvalCtx { expr: memo.as_ref().map(|h| h as &dyn ExprHost), ..*ctx }; + targets_in(&ctx, exclude, opts) +} + +fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec { let comp = ctx.comp; let (w, h) = (comp.width as f64, comp.height as f64); let f = opts.features; @@ -1063,3 +1100,45 @@ pub struct Snapshot { pub scale: f64, pub image: Arc, } + +#[cfg(test)] +mod snap_expression_tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct PositionHost(AtomicUsize); + impl ExprHost for PositionHost { + fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result { + self.0.fetch_add(1, Ordering::Relaxed); + let v = value.components(); + Ok(Value::Vec3([v[0] + 10. * ctx.time.seconds(), v[1], 0.])) + } + } + + #[test] + fn snapping_reuses_expressions_within_one_query_but_refreshes_time_and_edits() { + let mut s = crate::Session::default(); + s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap(); + let lid = s.execute("layer.newSolid", json!({"width":40,"height":20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer":lid,"path":"transform/position","expression":"value"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let host = PositionHost(AtomicUsize::new(0)); + let opts = SnapOptions { layers: true, ..Default::default() }; + let query = |s: &crate::Session, time, cached| { + let ctx = EvalCtx { expr: Some(&host), ..EvalCtx::new(&s.project, cid, s.project.comp(cid).unwrap(), time) }; + if cached { targets(&ctx, &[], opts) } else { targets_in(&ctx, &[], opts) } + }; + let expected = query(&s, Tick::ZERO, false); + assert!(host.0.swap(0, Ordering::Relaxed) > 1, "fixture must visit the same expression for multiple geometry features"); + assert_eq!(query(&s, Tick::ZERO, true), expected); + assert_eq!(host.0.swap(0, Ordering::Relaxed), 1); + let moved = query(&s, Tick::from_seconds_f64(1.), true); + assert_ne!(moved, expected, "a new time must not reuse stale targets"); + assert_eq!(host.0.swap(0, Ordering::Relaxed), 1); + s.execute("prop.set", json!({"layer":lid,"path":"transform/position","value":[200,100,0]})).unwrap(); + let edited = query(&s, Tick::ZERO, true); + assert_ne!(edited, expected, "a new project revision must not reuse stale targets"); + assert_eq!(host.0.swap(0, Ordering::Relaxed), 1); + } +} diff --git a/crates/engine/src/xml_project.rs b/crates/engine/src/xml_project.rs index ae899d46..34ed07ca 100644 --- a/crates/engine/src/xml_project.rs +++ b/crates/engine/src/xml_project.rs @@ -18,6 +18,11 @@ use serde_json::{Map, Value}; const ROOT: &str = "EffectCraftProject"; +/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at +/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply +/// nested file overflowed the stack and aborted the app instead of failing to open (#779). +const MAX_DEPTH: usize = 128; + /// Whether `text` looks like an XML project. pub fn is_xml(text: &str) -> bool { let t = text.trim_start_matches('\u{feff}').trim_start(); @@ -158,7 +163,10 @@ impl<'a> P<'a> { self.i += end; t } - fn value(&mut self) -> Result { + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels")); + } let (name, _, closed) = self.open()?; let v = match (name.as_str(), closed) { ("null", _) => Value::Null, @@ -175,7 +183,7 @@ impl<'a> P<'a> { if self.rest().starts_with(" P<'a> { if tag != "m" { return self.err("expected "); } - let v = self.value()?; + let v = self.value(depth + 1)?; self.close("m")?; m.insert(key.unwrap_or_default(), v); } @@ -243,7 +251,7 @@ pub fn from_xml(text: &str) -> Result { if root != ROOT { return Err(format!("not an EffectCraft XML project (root <{root}>)")); } - let v = p.value()?; + let v = p.value(0)?; p.close(ROOT)?; serde_json::to_string(&v).map_err(|e| e.to_string()) } @@ -262,4 +270,18 @@ mod tests { assert_eq!(back, j); assert!(from_xml("").is_err()); } + + /// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the + /// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads. + #[test] + fn deep_nesting_is_refused_not_a_stack_overflow() { + let nested = |n: usize| format!("<{ROOT}>{}{}", "".repeat(n), "".repeat(n)); + let e = from_xml(&nested(1_000_000)).unwrap_err(); + assert!(e.contains("nested deeper"), "{e}"); + let ok = from_xml(&nested(100)).unwrap(); + assert!(serde_json::from_str::(&ok).is_ok()); + let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1)); + let v: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(serde_json::from_str::(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v); + } } diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index add94b7c..4963810c 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -22,7 +22,10 @@ //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read //! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters -//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels. +//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs +//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`] +//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused +//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost}; use effectcraft_time::Tick; use host::{Own, Req, Resolver, prop_dims, value_resp}; -use runtime::{Begin, FRAMES, Frame, Run}; +use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run}; /// Maximum nesting of expressions that read other expressions (cycles stop here). pub const MAX_DEPTH: usize = 16; /// Maximum script re-runs while answering host requests. const MAX_PASSES: usize = 2000; +/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads +/// that fan out (an expression reading two values whose expressions each read two more…) grow +/// exponentially with [`MAX_DEPTH`] and would hang the frame. +pub const MAX_RUNS: usize = 20_000; +/// Longest one outermost evaluation may take (its script runs and the expressions it reads), +/// checked between runs and while a script runs: endless or very long loops, and a script that +/// loops a while and then reads many values (re-running the loop once per value), stop here. +/// Native builds only (the web build has no clock here; loops stop at the loop limit there). +pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5); + +/// Start the work budget of an outermost evaluation. +fn budget_start() { + RUNS.with(|r| r.set(0)); + #[cfg(not(target_arch = "wasm32"))] + runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now()))); +} + +/// An error once the outermost evaluation has used up its runs or its time. +fn budget_left() -> Result<(), String> { + if RUNS.with(|r| r.get()) >= MAX_RUNS { + return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into()); + } + if runtime::out_of_time() { + return Err(runtime::TIMED_OUT.into()); + } + Ok(()) +} /// The expression engine (stateless; runtimes are per thread). #[derive(Clone, Copy, Debug, Default)] @@ -111,10 +141,23 @@ fn evaluate_out( text_sel: [f64; 2], selector: [f64; 3], ) -> Result { - let depth = FRAMES.with(|f| f.borrow().len()); + let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() }; + let (depth, cycle) = FRAMES.with(|f| { + let f = f.borrow(); + (f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner))) + }); + if depth == 0 { + budget_start(); + } + if cycle { + // Needs its own result: fails at once (the reader falls back to the keyframed value) + // instead of nesting to `MAX_DEPTH` once per read on the way. + return Err("Error: circular expression reference (the expression needs its own value)".into()); + } if depth >= MAX_DEPTH { return Err("Error: expressions reference each other too deeply (circular reference?)".into()); } + budget_left()?; let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); @@ -133,7 +176,7 @@ fn evaluate_out( selector, }; // Answer the requests nearly every expression makes up front. - let mut frame = Frame::default(); + let mut frame = Frame { owner: Some(owner), ..Frame::default() }; for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] { let r = resolver.resolve(&req); frame.memo.insert(req, r); @@ -141,6 +184,8 @@ fn evaluate_out( FRAMES.with(|f| f.borrow_mut().push(frame)); let _guard = FrameGuard; for _ in 0..MAX_PASSES { + budget_left()?; + RUNS.with(|r| r.set(r.get().saturating_add(1))); match runtime::run(text, &begin) { Run::Done(r) => return r, Run::Pending => { diff --git a/crates/expr/src/noise.rs b/crates/expr/src/noise.rs index 854f15ce..0feddbbd 100644 --- a/crates/expr/src/noise.rs +++ b/crates/expr/src/noise.rs @@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 { let i = x.floor(); let u = fade(x - i); let i = i as i64; - let (a, b) = (lattice(seed, i), lattice(seed, i + 1)); + let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1))); a + (b - a) * u } @@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 { let (fx, fy, fz) = (x.floor(), y.floor(), z.floor()); let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz)); let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64); - let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0; + let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0; let l = |a: f64, b: f64, t: f64| a + (b - a) * t; let x00 = l(g(0, 0, 0), g(1, 0, 0), u); let x10 = l(g(0, 1, 0), g(1, 1, 0), u); @@ -99,6 +99,18 @@ mod tests { assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5))); } + #[test] + fn huge_inputs_do_not_overflow() { + // Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time + // put the lattice index at `i64::MAX`, and its neighbour overflowed. + let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6); + assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0)); + for x in [1e300, -1e300, 9.3e18, -9.3e18] { + assert!(noise1(5, x).is_finite()); + assert!(noise3(x, -x, x).is_finite()); + } + } + #[test] fn wiggle_bounds() { for i in 0..2000 { diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index ca54d231..55e9800a 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) { out.push_str(&p.body); } -/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have, -/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript -/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at -/// about 60 levels, so deeper expressions are rejected with an error instead. +/// The deepest nesting an expression may have, in levels of brackets, before and after the +/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler +/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75 +/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that +/// nest without brackets count as part of a level ([`too_nested`]). pub const MAX_NESTING: usize = 32; -fn nesting(toks: &[Tok]) -> usize { - let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize); - for t in toks { - if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") { - depth += 1; - } else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") { - depth = depth.saturating_sub(1); +/// Stack cost of one level of brackets (also of a template substitution, a regex group and a +/// prefix operator, which cost the parser about as much or are never chained in real code). +const LEVEL: usize = 256; +/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments, +/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a +/// bracket: 256 of them in a row are allowed; boa overflows from about 750). +const HEAVY: usize = 32; +/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing, +/// the comma operator): the tree is built in a loop but compiled recursively (overflows from +/// about 3 800 links). +const LIGHT: usize = 6; + +/// One bracket level of [`too_nested`]'s walk. +#[derive(Default)] +struct Level { + /// `[`: commas separate elements (a loop in the parser), not operands. + array: bool, + /// Cost of the chains of the current statement or element. + chain: usize, + /// Comma operators so far in the current statement. + commas: usize, + /// `else` branches in a row: `if … else if … else if …` nests even across `;`. + elses: usize, + /// The next token starts a statement. + fresh: bool, +} + +impl Level { + fn boundary(&mut self) { + self.chain = 0; + self.commas = 0; + self.fresh = true; + } +} + +const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="]; + +/// Can `t` end an expression statement (so a line break after it may end the statement)? +fn ends_value(t: &Tok) -> bool { + match t.kind { + Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true, + Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW), + Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"), + } +} + +/// Can `t` only start a new statement after a value (automatic semicolon insertion)? +fn starts_statement(t: &Tok) -> bool { + match t.kind { + Kind::Num | Kind::Str => true, + Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]), + Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"), + Kind::Template | Kind::Regex => false, + } +} + +/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups). +fn inner_depth(text: &str) -> usize { + let (mut d, mut max) = (0usize, 0usize); + for c in text.chars() { + match c { + '(' | '[' | '{' => d += 1, + ')' | ']' | '}' => d = d.saturating_sub(1), + _ => {} } - let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]); - prefix = if is_prefix { prefix + 1 } else { 0 }; - max = max.max(depth + prefix); + max = max.max(d); } max } +/// The code of a template literal's `${…}` substitutions (the outermost ones). +fn substitutions(template: &str) -> Vec { + let c: Vec = template.chars().collect(); + let mut out = vec![]; + let mut i = 1; + while i < c.len() { + match c[i] { + '\\' => i += 2, + '$' if c.get(i + 1) == Some(&'{') => { + let start = i + 2; + let (mut j, mut depth) = (start, 0usize); + while j < c.len() { + match c[j] { + '{' => depth += 1, + '}' if depth == 0 => break, + '}' => depth -= 1, + '\'' | '"' => { + j = skip_string(&c, j); + continue; + } + '`' => { + j = skip_template(&c, j); + continue; + } + _ => {} + } + j += 1; + } + out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect()); + i = j + 1; + } + _ => i += 1, + } + } + out +} + +/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack +/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the +/// current statement (or array element) weighted by what each link costs the parser. +fn too_nested(toks: &[Tok]) -> bool { + too_nested_from(toks, 0) +} + +/// [`too_nested`] for code that already sits `base` deep (a template substitution). +fn too_nested_from(toks: &[Tok], base: usize) -> bool { + let mut levels = vec![Level::default()]; + let mut prefix = 0usize; + let mut prev: Option<&Tok> = None; + for t in toks { + let p = t.kind == Kind::Punct; + if let Some(l) = levels.last_mut() { + if t.nl && prev.is_some_and(ends_value) && starts_statement(t) { + l.boundary(); + } + if l.fresh { + if !t.is_kw(&["else"]) { + l.elses = 0; + } + l.fresh = false; + } + } + let mut inner = 0; + if p && matches!(t.text.as_str(), "(" | "[" | "{") { + // A call or index is a link in its chain too (`f()()()…`). + if t.text != "{" + && let Some(l) = levels.last_mut() + { + l.chain = l.chain.saturating_add(LIGHT); + } + levels.push(Level { array: t.text == "[", ..Level::default() }); + } else if p && matches!(t.text.as_str(), ")" | "]" | "}") { + if levels.len() > 1 { + levels.pop(); + } + } else if let Some(l) = levels.last_mut() { + if t.is(";") { + l.boundary(); + } else if t.is(",") { + l.commas += usize::from(!l.array); + l.chain = l.commas.saturating_mul(LIGHT); + } else if t.is_kw(&["else"]) { + l.elses += 1; + } else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str()))) + || t.is_kw(&["if", "while", "for", "with", "do"]) + { + l.chain = l.chain.saturating_add(HEAVY); + } else if p || t.is_kw(&["in", "instanceof"]) { + l.chain = l.chain.saturating_add(LIGHT); + } else if matches!(t.kind, Kind::Template | Kind::Regex) { + // A tagged template is a link like a call. + l.chain = l.chain.saturating_add(LIGHT); + inner = inner_depth(&t.text); + } + } + let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]); + prefix = if is_prefix { prefix + 1 } else { 0 }; + let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base); + for l in &levels { + cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY)); + } + if cost > MAX_NESTING * LEVEL { + return true; + } + // The code inside `${…}` is parsed like any other, one level deeper (the depth check + // above bounds this recursion). + if t.kind == Kind::Template + && t.text.contains("${") + && substitutions(&t.text) + .iter() + .any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL))) + { + return true; + } + prev = Some(t); + } + false +} + /// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite). pub fn check_nesting(src: &str) -> Result<(), String> { let (toks, tail) = tokenize(src); - if nesting(&toks) > MAX_NESTING { + if too_nested(&toks) { return Err(too_deep()); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); - if nesting(&tokenize(&out).0) > MAX_NESTING { + if too_nested(&tokenize(&out).0) { return Err(too_deep()); } Ok(()) } fn too_deep() -> String { - format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)") + format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)") } /// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved. /// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error. pub fn rewrite(src: &str) -> String { let (toks, tail) = tokenize(src); - if nesting(&toks) > MAX_NESTING { + if too_nested(&toks) { return throw_too_deep(); } let mut out = rewrite_seq(&build(toks)); out.push_str(&tail); - if nesting(&tokenize(&out).0) > MAX_NESTING { + if too_nested(&tokenize(&out).0) { return throw_too_deep(); } out @@ -673,4 +848,60 @@ mod tests { assert!(crate::check_syntax(&ok).is_ok()); assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok()); } + + #[test] + fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() { + // Found by the expression fuzzer (#786): the parser also recurses on chains that need no + // bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links, + // aborted the app with a stack overflow; so did 75 nested template substitutions or + // regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter. + let chains: [(&str, &str); 19] = [ + ("x?x:", ""), + ("()=>", ""), + ("a=>", ""), + ("if(x)", ""), + ("if(x)x;else ", ""), + ("x=", ""), + ("x**", ""), + ("x||", ""), + ("x<", ""), + ("x,", ""), + ("x.y", ""), + ("f()", ""), + ("a:", ""), + ("new ", ""), + ("++", ""), + ("`${", "}`"), + ("s.replace(/(", ")/, '')"), + ("`${x??", "}`"), + ("{a:", "}"), + ]; + for (open, close) in chains { + for n in [2_000, 100_000] { + let src = open.repeat(n) + "1" + &close.repeat(n); + assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}"); + assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}"); + } + } + // A chain inside one template substitution (the template is one token to the rewriter). + let src = format!("`${{{}1}}`", "x??".repeat(100_000)); + assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply")); + } + + #[test] + fn long_real_expressions_still_parse() { + let lines = |n: usize, line: &str| vec![line; n].join("\n"); + let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::() + "0"; + let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::>().join(", ")); + let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::>().join(", ")); + let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::>().join(", ")); + let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx"; + let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny"; + let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + "); + let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string(); + for src in [else_ifs, numbers, points, object, program, ifs, chained, template] { + assert!(check_nesting(&src).is_ok(), "{}", &src[..80]); + assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]); + } + } } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index bb841e6d..142658b1 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js"); /// Message of the error thrown when a request isn't answered yet (never shown to users). const PENDING: &str = "\u{1}effectcraft: pending host request"; const MAX_SCRIPTS: usize = 4096; +/// Loop iterations one script run may make (per function call): a run re-executes once per +/// property it reads, so this bounds the time of every pass. +pub const LOOP_LIMIT: u64 = 1_000_000; +/// The error of an evaluation that ran out of time. +pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate"; /// The part of boa's stack-size limit error the runtime recovers from (see [`run`]). const STACK_LIMIT: &str = "maximum stack size"; @@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size"; pub struct Frame { pub memo: HashMap, pub misses: Vec, + /// What this frame evaluates: an evaluation that needs itself is a cycle. + pub owner: Option, +} + +/// One expression evaluation: the property (comp, layer, uid), the comp time and the text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Owner { + pub comp: u64, + pub layer: u64, + pub uid: u64, + pub time: effectcraft_time::Tick, + pub text: String, } thread_local! { @@ -32,6 +49,14 @@ thread_local! { static RUNTIME: RefCell>>> = const { RefCell::new(None) }; /// Evaluation stack (nested when one expression reads another's value). pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; + /// Script runs since the outermost evaluation on this thread began (nested ones included). + pub static RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + /// When the outermost evaluation on this thread began. + pub static STARTED: std::cell::Cell> = const { std::cell::Cell::new(None) }; } struct Runtime { @@ -209,7 +234,7 @@ impl Runtime { fn new() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); - limits.set_loop_iteration_limit(10_000_000); + limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ (js_string!("__h"), 6, native_host), @@ -270,7 +295,11 @@ impl Runtime { if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) { return Run::Done(Err(format_error(&e.to_string(), line_count(text)))); } - let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)); + let result = match evaluate_in_time(&script, ctx) { + Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)), + // Abandoned mid-run: the context is unusable now (see `run`). + None => return Run::Done(Err(TIMED_OUT.into())), + }; let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty())); if pending { return Run::Pending; @@ -282,6 +311,54 @@ impl Runtime { } } +/// Run `script`, giving up (`None`) once the outermost evaluation is out of time +/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of +/// the script and the functions it calls (not inside callbacks of native functions such as +/// `Array.prototype.map`, which stay bounded by the loop limit per call). +fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option> { + let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000)); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + loop { + if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) { + return Some(r); + } + if out_of_time() { + return None; + } + } +} + +#[cfg(test)] +thread_local! { + /// Test hook: this thread's time budget (tests of the run-count budget lift it so that a + /// loaded machine cannot reach the clock first). + pub static TIME_LIMIT: std::cell::Cell = const { std::cell::Cell::new(crate::MAX_TIME) }; +} + +#[cfg(not(target_arch = "wasm32"))] +fn max_time() -> std::time::Duration { + #[cfg(test)] + { + TIME_LIMIT.with(|t| t.get()) + } + #[cfg(not(test))] + { + crate::MAX_TIME + } +} + +/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`]. +pub fn out_of_time() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} + /// Copy a JS value out of the heap. fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { if v.is_undefined() { @@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run { Err(e) => return Run::Done(Err(e.clone())), }; match out { + Run::Done(Err(e)) if e == TIMED_OUT => { + // Abandoned mid-run: start the next evaluation on a fresh context. + if let Some(old) = r.take() { + drop(ManuallyDrop::into_inner(old)); + } + return Run::Done(Err(e)); + } Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => { // Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit). if let Some(old) = r.take() { @@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result { Value::Str(_) => Value::Str(text(out)), Value::Path(_) => match out { Out::Path { points, ins, outs, closed } => { + // A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which + // the rasterizer must never see. + if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) { + return Err("Error: path points must be finite numbers".into()); + } let n = points.len(); let fit = |v: &[[f64; 2]]| { let mut v = v.to_vec(); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 133d32b5..93a25562 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1,5 +1,7 @@ use std::time::Instant; +mod fuzz; + use effectcraft_color::Label; use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value}; use effectcraft_project::build::{self, Ids}; @@ -383,11 +385,87 @@ fn circular_references_terminate() { f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity"); let start = Instant::now(); let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0); - assert!(start.elapsed().as_secs_f64() < 10.0); - // The cycle bottoms out at the depth limit and falls back to keyframed values. + // Only a hang guard (the budgets stop it far sooner); generous for a loaded machine. + assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed()); + // The cycle is caught when A needs itself again, and falls back to keyframed values. assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}"); } +#[test] +fn an_expression_needing_its_own_value_fails_fast() { + // Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform, + // which evaluates rotation's expression again, twice per level (layer-to-comp and world + // matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s. + let mut f = fx(); + f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]"); + let start = Instant::now(); + let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5); + assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed()); + // The inner read falls back to the keyframed rotation, as in a standalone evaluation. + assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5)); +} + +#[test] +fn fanning_out_reads_stop_at_a_budget() { + // Two layers reading each other twice at other times: no cycle (the times differ), but + // 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs. + let mut f = fx(); + let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)"); + f.set_expr(f.a, ROT, &text("B")); + f.set_expr(f.b, ROT, &text("A")); + // Take the clock out of the race: on a loaded machine the time budget could otherwise stop + // the evaluation before the run count does, with a different error. + crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600))); + let start = Instant::now(); + let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5); + crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME)); + assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed()); + assert!(r.unwrap_err().contains("too many")); + // The budget is per outermost evaluation: the next one starts afresh. + assert_close!(f.num("time * 2", 1.0), 2.0); + assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0); +} + +#[test] +fn loops_stop_at_a_million_iterations() { + // Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations + // per run (seconds), once more for every property the script reads after it. + let f = fx(); + assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0); + assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration")); + assert!(f.err("for (;;) {}").contains("iteration")); +} + +#[test] +fn a_long_running_script_stops_at_the_time_budget() { + // Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in + // one run (tens of seconds), and the loop limit counts per function call, so loops calling + // functions that loop run for hours. The run is now abandoned after `MAX_TIME`. + let f = fx(); + let start = Instant::now(); + let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()"); + assert!(e.contains("too long"), "{e}"); + assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed()); + // The thread's runtime starts afresh. + assert_close!(f.num("time * 2", 1.0), 2.0); +} + +#[test] +fn a_path_with_non_numeric_points_is_an_error() { + // Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the + // rasterizer's clipping assertion when the mask was drawn. + let mut f = fx(); + let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)"; + assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite")); + assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err()); + f.set_expr(f.a, "masks/#1/path", text); + let host = Expressions; + let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() }); + r.expr = Some(&host); + r.comp_frame(f.cid, Tick::ZERO); + assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok()); +} + #[test] fn errors_and_property_reads_keep_the_runtime_usable() { // Errors thrown inside helpers, and the aborts every property read makes until the host @@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() { let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64; println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})"); // Generous bound so unoptimised CI builds pass; release is far below. - assert!(per_frame < 100.0, "{per_frame} ms/frame"); + assert!(per_frame < 1000.0, "{per_frame} ms/frame"); } #[test] diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs new file mode 100644 index 00000000..b9fb752b --- /dev/null +++ b/crates/expr/src/tests/fuzz.rs @@ -0,0 +1,361 @@ +//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an +//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation +//! (AGENTS.md "Never crash"). +//! +//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`, +//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…) +//! are mutated character-wise (flips, deletions, duplications, splices with other seeds, +//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers +//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then +//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp, +//! read through another layer's expression and rendered in a small frame. Inputs run on a worker +//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a +//! hang is reported with the iteration that reproduces it instead of taking the run down, and a +//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1` +//! prints every input before it runs). +//! +//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run: +//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_` +//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration). +//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call. + +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::{Duration, Instant}; + +use effectcraft_render::{NoFootage, RenderOpts, Renderer}; + +use super::{Fx, OPA, POS, ROT, fx, secs}; +use crate::{Expressions, check_syntax, eval_property, eval_standalone}; + +/// splitmix64: tiny, good enough for picking mutations. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// `0..n` (`0` when `n` is 0). + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn chance(&mut self, one_in: usize) -> bool { + self.below(one_in) == 0 + } + fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T { + &xs[self.below(xs.len())] + } +} + +/// Real expressions, in the shapes people write them (and the test comp can answer them). +#[rustfmt::skip] +const SEEDS: &[&str] = &[ + "time * 360", + "value", + "wiggle(2, 50)", + "wiggle(5, 20, 3, 0.5, time * 2)", + "temporalWiggle(2, 0.1)", + "loopOut('cycle')", + "loopOut('pingpong', 1)", + "loopIn('offset')", + "loopOutDuration('continue', 0.5)", + "valueAtTime(time - 0.5)", + "thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]", + "speedAtTime(0.5)", + "smooth(0.2, 5)", + "nearestKey(time).index", + "numKeys > 1 ? key(numKeys).value : value", + "thisComp.layer('A').transform.rotation.valueAtTime(0.25)", + "thisComp.layer(\"B\").transform.position[0] + 10", + "thisComp.layer(1).effect('Slider Control')('Slider') * 2", + "effect(1).param(1) + effect(1)(1)", + "comp('Other').layer(1).name", + "comp('Other').width / thisComp.height", + "thisLayer.index + thisComp.numLayers", + "mask('Mask 1').maskOpacity", + "mask('Mask 1').maskPath.points()[0]", + "content('Rectangle 1').content('Rectangle Path 1').size[0]", + "marker.numKeys > 0 ? marker.key(1).time : 0", + "marker.nearestKey(time).comment", + "thisComp.marker.key(1).time", + "var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]", + "toComp([0, 0])", + "fromWorld(toWorld(anchorPoint))", + "lookAt([0, 0, 0], [100, 0, 100])", + "value + [10, -5]", + "[1, 2] * 2 - [0.5, 0.5] / 4", + "x = value; x += [1, 0]; x", + "linear(time, 0, 1, 0, 100)", + "ease(time, 0, 2, [0, 0], [100, 50])", + "easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)", + "clamp(value * 2, 0, 100)", + "length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]", + "normalize([3, 4])[0] * degreesToRadians(180)", + "seedRandom(5, true); random(100)", + "seedRandom(index, false); gaussRandom([10, 20])", + "noise(time) * 100", + "posterizeTime(12); wiggle(3, 30)", + "framesToTime(timeToFrames(time) + 1)", + "rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]", + "Math.sin(time * Math.PI * 2) * 50", + "Math.round(value / 10) * 10", + "var s = 0; for (var i = 0; i < 10; i++) { s += i; } s", + "function f(a) { return a * 2 } f(value)", + "var g = (a, b) => a - b; g(5, 3)", + "if (time > 1) { 100 } else { 0 }", + "try { thisComp.layer('Missing').name } catch (e) { 0 }", + "'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration", + "text.sourceText + ' world'", + "text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)", + "thisComp.layer('Title').text.sourceText.style.fontSize", + "`${thisLayer.name}: ${Math.floor(time)}`", + "createPath([[0, 0], [100, 0], [100, 100]], [], [], true)", + "var p = thisProperty; p.pointOnPath(0.5)", + "[thisComp.width, thisComp.height] / 2", + "thisComp.frameDuration * 10", + "inPoint + (outPoint - inPoint) * 0.5", + "transform.opacity * 0.5 + [0][0]", +]; + +/// What gets inserted: syntax, keywords, object-model names and odd characters. +#[rustfmt::skip] +const TOKENS: &[&str] = &[ + "(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--", + "+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t", + "\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ", + "function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try", + "catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n", + "/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(", + "content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText", + ".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments", +]; + +/// Numbers swapped in for numeric literals. +#[rustfmt::skip] +const NUMBERS: &[&str] = &[ + "0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993", + "1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff", +]; + +/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser. +#[rustfmt::skip] +const NESTS: &[(&str, &str)] = &[ + ("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""), + ("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""), + ("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""), + ("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""), + ("x.y", ""), ("f()", ""), ("x[0]", ""), +]; + +/// One mutated expression from the seeds, decided by the iteration's own generator. +fn mutate(r: &mut Rng) -> String { + let mut s: Vec = r.pick(SEEDS).chars().collect(); + if r.chance(4) { + // Splice two expressions together. + let other: Vec = r.pick(SEEDS).chars().collect(); + let at = r.below(s.len() + 1); + let glue: Vec = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect(); + s.splice(at..at, glue.into_iter().chain(other)); + } + for _ in 0..1 + r.below(4) { + let len = s.len(); + match r.below(10) { + 0 if len > 0 => { + // A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD). + let mut b = s.iter().collect::().into_bytes(); + let i = r.below(b.len()); + b[i] ^= 1 << r.below(8); + s = String::from_utf8_lossy(&b).chars().collect(); + } + 1 if len > 0 => { + let a = r.below(len); + let e = (a + 1 + r.below(16)).min(len); + s.drain(a..e); + } + 2 if len > 0 => { + let a = r.below(len); + let e = (a + 1 + r.below(32)).min(len); + let chunk = s[a..e].to_vec(); + let at = r.below(len + 1); + s.splice(at..at, chunk); + } + 3 | 4 => { + let t: Vec = r.pick(TOKENS).chars().collect(); + let at = r.below(len + 1); + s.splice(at..at, t); + } + 5 => { + // An extreme number in place of a numeric literal (or anywhere). + let starts: Vec = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect(); + let n: Vec = r.pick(NUMBERS).chars().collect(); + if starts.is_empty() { + let at = r.below(len + 1); + s.splice(at..at, n); + } else { + let a = *r.pick(&starts); + let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len); + s.splice(a..e, n); + } + } + 6 => { + // Deep nesting or a long chain, closed or not. + let (open, close) = *r.pick(NESTS); + let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]); + let at = r.below(len + 1); + let mut nest: Vec = open.repeat(depth).chars().collect(); + nest.push('1'); + if !r.chance(4) { + nest.extend(close.repeat(depth).chars()); + } + s.splice(at..at, nest); + } + 7 if len > 0 => s.truncate(r.below(len)), + 8 => { + // A whole statement in front or behind. + let other: Vec = r.pick(SEEDS).chars().collect(); + if r.chance(2) { + s.extend(";\n".chars().chain(other)); + } else { + s.splice(0..0, other.into_iter().chain(";\n".chars())); + } + } + _ => {} + } + } + s.into_iter().collect() +} + +/// Where an input is evaluated: a scalar, a vector, a text and a path property. +const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)]; +const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6]; + +/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression +/// and render a small frame with it. `Err` only for what must never happen: an `internal error` +/// (a panic caught by a guard). +fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> { + internal(&check_syntax(text))?; + let layers = [f.a, f.b, f.title, f.shape]; + let (li, path) = TARGETS[target % TARGETS.len()]; + let layer = layers[li]; + internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?; + // As the property's own expression, read by B's rotation and drawn by the renderer. + f.set_expr(layer, path, text); + f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1)); + internal(&eval_property(&f.p, f.cid, layer, path, t))?; + internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?; + let host = Expressions; + let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() }); + r.expr = Some(&host); + r.comp_frame(f.cid, secs(t)); + // Leave the fixture as it was. + let l = f.layer_mut(layer); + if let Some(p) = l.props.prop_mut(path) { + p.expr = None; + } + if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) { + p.expr = None; + } + Ok(()) +} + +fn internal(r: &Result) -> Result<(), String> { + match r { + Err(e) if e.contains("internal error") => Err(e.clone()), + _ => Ok(()), + } +} + +struct Job { + text: String, + target: usize, + t: f64, +} + +/// A worker thread that keeps its expression runtime between inputs, like a render thread. +struct Worker { + jobs: Sender, + results: Receiver>, +} + +impl Worker { + fn start() -> Result { + let (jobs, rx) = channel::(); + let (tx, results) = channel(); + std::thread::Builder::new() + .name("fuzz-expr".into()) + .stack_size(2 << 20) + .spawn(move || { + let mut f = fx(); + for job in rx { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t))); + let r = r.unwrap_or_else(|p| { + Err(format!( + "panic: {}", + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default() + )) + }); + let failed = r.is_err(); + if tx.send(r).is_err() || failed { + break; // after a panic the fixture and runtime may be half-updated: start over + } + } + }) + .map_err(|e| format!("couldn't start the fuzz thread: {e}"))?; + Ok(Worker { jobs, results }) + } + + /// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped). + fn run(&self, job: Job, deadline: Duration) -> Result<(), String> { + self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?; + self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}"))) + } +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default) +} + +#[test] +fn fuzz_expressions_never_crash() { + let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2); + let start = env_u64("EFFECTCRAFT_FUZZ_START", 0); + let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500); + let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5)); + let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0; + // Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times. + let deadline = crate::MAX_TIME * 24; + let mut worker = Worker::start().unwrap(); + // The unmutated seeds evaluate everywhere without crashing. + for (i, s) in SEEDS.iter().enumerate() { + assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}"); + } + let began = Instant::now(); + let mut failures = vec![]; + let mut ran = 0; + for i in start..start + iters { + if began.elapsed() > budget { + break; + } + let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93)); + let text = mutate(&mut r); + let target = r.below(TARGETS.len()); + let t = *r.pick(TIMES); + if verbose { + eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::()); + } + ran += 1; + if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) { + let head: String = text.chars().take(200).collect(); + failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len())); + if failures.len() >= 5 { + break; + } + worker = Worker::start().unwrap(); + } + } + assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n")); + eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed()); +} diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d7..42d2bab7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,6 +145,21 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { + let img = self.e.image(w, h); + self.e.copy_into(&b.img, &img, dx, dy); + GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs index e7fd628a..71f7a582 100644 --- a/crates/gpu/src/fx_time.rs +++ b/crates/gpu/src/fx_time.rs @@ -45,11 +45,17 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option>()) } - // Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that - // cannot be built (no host) declines to the CPU effect. + // Pixel Motion Blur synthesizes along measured motion vectors (#535); without a plan (no + // host to read neighbouring frames) it averages the shutter's frames, as the CPU effect does. "ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) { Some(plan) => render_plan(e, &plan), - None => None, + None => { + 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(e, ctx, b, ×) + } }, "ec.time.timewarp" => timewarp(e, ctx, b), _ => None, diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 7fd3702a..1f62d442 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -665,7 +665,7 @@ fn fxn_fractal(@builtin(global_invocation_id) gid: vec3) { r = blend_pixel(P.u[2].x, orig, s, 0.5); } let bo = P.f[1].w; - textureStore(out, p, orig * bo + r * (1.0 - bo)); + textureStore(out, p, orig * bo + r * orig.a * (1.0 - bo)); } // ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 15ace4fa..406472e7 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -865,13 +865,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { val = 1.0 - val; } let op = P.f[2].z; + var fx = vec4(vec3(val) * op, op); if (P.u[1].z == 1u) { - // Normal: the noise over the layer at Opacity, then Blend With Original. - let over = vec4(vec3(val) * op, op) + px * (1.0 - op); - px = px * P.f[2].y + over * (1.0 - P.f[2].y); - } else { - px = put(px, vec3(val), op, P.f[2].y); + // Normal: the noise over the layer at Opacity. + fx = fx + px * (1.0 - op); } + // Inside the layer's alpha (its masks), then Blend With Original. + px = px * P.f[2].y + fx * px.w * (1.0 - P.f[2].y); } default: {} } diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 51951a26..e0ee137b 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -287,7 +287,9 @@ impl SpatialSeg { /// along curved paths instead of piecewise linear per cell (#456). fn at_length(&self, s: f64) -> [f64; 3] { let len = self.length(); - if len <= 0.0 { + // A path whose length overflowed (huge coordinates) or is NaN has no arc-length table to + // walk: `clamp` would panic on a NaN bound. + if !(len > 0.0 && len.is_finite()) { return self.p[0]; } let s = s.clamp(0.0, len); @@ -878,6 +880,30 @@ mod tests { assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); } + /// #880: a motion path whose arc length overflows (huge coordinates, as the Position field + /// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app. + #[test] + fn overflowing_or_nan_motion_paths_evaluate() { + let paths = [ + [[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]], + [[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]], + [[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]], + ]; + for path in paths { + let mut keys: Vec = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect(); + for eased in [false, true] { + if eased { + keys = keys.into_iter().map(Keyframe::eased).collect(); + } + for f in 0..=40 { + let t = s(f as f64 * 0.1); + assert!(evaluate(&keys, t, true).is_some()); + let _ = speed(&keys, t, true); + } + } + } + } + #[test] fn spatial_auto_bezier_tangents_follow_after_effects() { // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 19a7ba31..a3ff624b 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -20,6 +20,7 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index f22e6e44..d483e4b4 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -114,6 +114,32 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - img } +/// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]: +/// clockwise quarter turns; anything else leaves it as decoded). +pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image { + use image::metadata::Orientation; + let (w, h) = (img.width as usize, img.height as usize); + if img.data.len() != w * h || w == 0 || h == 0 { + return img; + } + // The source pixel shown at (x, y) of the turned picture. + let (tw, th, from): (usize, usize, fn(usize, usize, usize, usize) -> usize) = match turn { + Orientation::Rotate90 => (h, w, |x, y, w, h| (h - 1 - x) * w + y), + Orientation::Rotate180 => (w, h, |x, y, w, h| (h - 1 - y) * w + (w - 1 - x)), + Orientation::Rotate270 => (h, w, |x, y, w, _| x * w + (w - 1 - y)), + _ => return img, + }; + let mut data = vec![[0.0; 4]; tw * th]; + data.par_chunks_mut(tw).enumerate().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if let Some(s) = img.data.get(from(x, y, w, h)) { + *px = *s; + } + } + }); + Image { width: tw as u32, height: th as u32, data } +} + /// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way: /// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels. /// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma, diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs new file mode 100644 index 00000000..2ef417dd --- /dev/null +++ b/crates/media/src/downmix.rs @@ -0,0 +1,133 @@ +//! Stereo downmix of multichannel audio, shared by preview, waveform and export mixdown (they +//! all read footage audio through `MediaPool::audio_samples`). +//! +//! The matrix is the usual ITU-R BS.775 one: front L/R at unity, the centre and every surround +//! or back channel at −3 dB (1/√2) into both sides' own side (centre into both), the low +//! frequency effects channel left out (as ffmpeg's and swresample's default, `lfe_mix_level` 0; +//! not verified against Adobe's documentation, which does not state it). A back-centre channel +//! reaches both sides at −6 dB. The whole matrix is then scaled by one fixed gain, the reciprocal +//! of the largest row sum (ffmpeg's default normalisation), so that full-scale input can never +//! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and +//! stereo is untouched. +//! +//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, +//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, +//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC +//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables. +//! More than 8 channels carry no known layout: only the first two (front L/R) are used. + +use std::f32::consts::FRAC_1_SQRT_2; + +/// A source channel's contribution to the left and right outputs, before normalisation. +type Pair = [f32; 2]; + +const FL: Pair = [1.0, 0.0]; +const FR: Pair = [0.0, 1.0]; +const FC: Pair = [FRAC_1_SQRT_2, FRAC_1_SQRT_2]; +const LFE: Pair = [0.0, 0.0]; +const SL: Pair = [FRAC_1_SQRT_2, 0.0]; +const SR: Pair = [0.0, FRAC_1_SQRT_2]; +const CS: Pair = [0.5, 0.5]; + +/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised. +/// `aac` selects AAC's native channel order. Empty for zero channels. +pub fn stereo_gains(count: usize, aac: bool) -> Vec { + let raw: &[Pair] = match (count, aac) { + (0, _) => &[], + (1, _) => &[[1.0, 1.0]], + (2, _) => &[FL, FR], + (3, false) => &[FL, FR, FC], + (3, true) => &[FC, FL, FR], + (4, false) => &[FL, FR, SL, SR], + (4, true) => &[FC, FL, FR, CS], + (5, false) => &[FL, FR, FC, SL, SR], + (5, true) => &[FC, FL, FR, SL, SR], + (6, false) => &[FL, FR, FC, LFE, SL, SR], + (6, true) => &[FC, FL, FR, SL, SR, LFE], + (7, false) => &[FL, FR, FC, LFE, CS, SL, SR], + (7, true) => &[FC, FL, FR, SL, SR, CS, LFE], + (8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR], + (8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE], + _ => &[FL, FR], + }; + let mut out = raw.to_vec(); + // Channels beyond a known layout (more than 8) are silent. + out.resize(count, [0.0, 0.0]); + let peak = out.iter().map(|g| g[0]).sum::().max(out.iter().map(|g| g[1]).sum::()); + // Mono is deliberately left at unity on both sides; so is anything already within range. + if count > 2 && peak > 1.0 { + for g in &mut out { + g[0] /= peak; + g[1] /= peak; + } + } + out +} + +/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a +/// channel is too short for count as silence, extra frames in `out` stay as they are. +pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { + let gains = stereo_gains(channels.len(), aac); + for (ch, g) in channels.iter().zip(&gains) { + if g[0] == 0.0 && g[1] == 0.0 { + continue; + } + for (o, v) in out.as_chunks_mut::<2>().0.iter_mut().zip(ch) { + o[0] += v * g[0]; + o[1] += v * g[1]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_channel_count_has_a_bounded_defined_matrix() { + for aac in [false, true] { + for n in 0..40usize { + let g = stereo_gains(n, aac); + assert_eq!(g.len(), n); + let (l, r) = (g.iter().map(|p| p[0]).sum::(), g.iter().map(|p| p[1]).sum::()); + if n > 2 { + assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}"); + assert!(l > 0.0 && r > 0.0); + } + } + } + } + + #[test] + fn five_one_matches_the_bs775_matrix() { + let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); + let g = stereo_gains(6, false); + assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0); + assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6); + assert_eq!(g[3], [0.0, 0.0], "LFE is left out"); + assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0); + assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0); + // AAC order: the centre is first, LFE last + let a = stereo_gains(6, true); + assert_eq!((a[0], a[5]), (g[2], g[3])); + assert_eq!((a[1], a[2]), (g[0], g[1])); + } + + #[test] + fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() { + assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]); + assert_eq!(stereo_gains(2, true), vec![FL, FR]); + let g = stereo_gains(12, false); + assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0])); + } + + #[test] + fn ragged_and_empty_input_never_panics() { + let mut out = vec![0.0; 8]; + downmix_into(&[], false, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out); + assert!(out.iter().all(|v| v.is_finite())); + let mut none: [f32; 0] = []; + downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none); + } +} diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs index 30161b0b..09c0cda3 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -3,11 +3,12 @@ //! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame //! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the //! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time. +//! Animated WebP ([`crate::webp_anim`]) shares [`Anim`] and this decode path. use std::io::Cursor; use effectcraft_project::{AlphaMode, Footage, FootageKind}; -use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use effectcraft_time::{FrameRate, Tick}; use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder}; use crate::{MediaError, Result}; @@ -15,19 +16,20 @@ use crate::{MediaError, Result}; /// The `Footage::codec` of an animated GIF. pub(crate) const CODEC: &str = "GIF"; -/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab, -/// whose wasm32 heap is 4 GiB at most and shared with everything else). +/// Decoded frames of one GIF or animated WebP are kept as 8-bit RGBA, up to this many bytes (less +/// in a browser tab, whose wasm32 heap is 4 GiB at most and shared with everything else). #[cfg(not(target_arch = "wasm32"))] -const MAX_BYTES: usize = 1 << 30; +pub(crate) const MAX_BYTES: usize = 1 << 30; #[cfg(target_arch = "wasm32")] -const MAX_BYTES: usize = 256 << 20; +pub(crate) const MAX_BYTES: usize = 256 << 20; -/// Frames read from a GIF's headers when probing. -const MAX_FRAMES: usize = 100_000; +/// Frames read from a GIF's or animated WebP's headers when probing. +pub(crate) const MAX_FRAMES: usize = 100_000; -/// Whether `f` is an animated GIF imported by [`probe`]. -pub(crate) fn is_gif(f: &Footage) -> bool { - f.kind == FootageKind::Video && f.codec == CODEC +/// Whether `f` is an animated GIF or WebP imported by [`probe`] or [`crate::webp_anim::probe`]: +/// its frames come from an [`Anim`]. +pub(crate) fn is_animated(f: &Footage) -> bool { + f.kind == FootageKind::Video && (f.codec == CODEC || f.codec == crate::webp_anim::CODEC) } /// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders @@ -36,7 +38,7 @@ fn delay_cs(raw: u32) -> u32 { if raw < 2 { 10 } else { raw } } -fn gcd(a: u32, b: u32) -> u32 { +pub(crate) fn gcd(a: u32, b: u32) -> u32 { if b == 0 { a } else { gcd(b, a % b) } } @@ -73,19 +75,27 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { }) } -/// Every frame of a GIF, composited onto the full canvas. +/// Every frame of a GIF or animated WebP, composited onto the full canvas. pub(crate) struct Anim { frames: Vec, - /// When each frame starts, in hundredths of a second. - starts: Vec, + /// When each frame starts (ascending, one per frame). + starts: Vec, } impl Anim { - /// Decode all frames. A damaged tail keeps the frames before it. + /// Frames and the times they start at (one each, ascending); `None` when there are none. + pub(crate) fn new(frames: Vec, starts: Vec) -> Option { + (!frames.is_empty() && frames.len() == starts.len()).then_some(Anim { frames, starts }) + } + + /// Decode all frames of a GIF or animated WebP. A damaged tail keeps the frames before it. pub(crate) fn load(bytes: &[u8]) -> Result { + if crate::webp_anim::is_webp(bytes) { + return crate::webp_anim::load(bytes); + } let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; let (mut frames, mut starts) = (Vec::new(), Vec::new()); - let (mut at, mut size) = (0u64, 0usize); + let (mut at, mut size) = (0i64, 0usize); for frame in dec.into_frames() { let frame = match frame { Ok(f) => f, @@ -102,21 +112,17 @@ impl Anim { if size > MAX_BYTES { return Err(MediaError::Unsupported("GIF is too large to hold in memory".into())); } - starts.push(at); - at = at.saturating_add(u64::from(cs)); + starts.push(Tick::from_units(at, 100)); + at = at.saturating_add(i64::from(cs)); frames.push(DynamicImage::ImageRgba8(buf)); } - if frames.is_empty() { - return Err(MediaError::Decode("GIF: no frames".into())); - } - Ok(Anim { frames, starts }) + Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("GIF: no frames".into())) } - /// The frame shown at source time `t` (the last one past the end). + /// The frame shown at source time `t` (the first one before the start, the last one past the + /// end). pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> { - let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128; - let cs = u64::try_from(cs).unwrap_or(u64::MAX); - let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1); + let i = self.starts.partition_point(|&s| s <= t).saturating_sub(1); self.frames.get(i).or(self.frames.last()) } } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index e65aa793..0fb40d81 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -40,6 +40,12 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) } +/// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles. +#[cfg_attr(not(any(unix, windows)), allow(dead_code))] +pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool { + effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes) +} + /// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { @@ -81,7 +87,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))? } else { - let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let img = crate::probe::load_oriented(data, None).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; let rgba = img.to_rgba32f(); effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() } }; diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 92a3ae73..04a9d95e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; +pub mod downmix; mod dpx; pub mod exr_channels; mod gif_anim; @@ -23,6 +24,8 @@ mod layered; pub use layered::vector_doc; mod pool; mod probe; +mod stream; +mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; @@ -36,6 +39,9 @@ pub enum MediaError { Unsupported(String), #[error("decode error: {0}")] Decode(String), + /// The read was called off before it finished (see [`effectcraft_render::FootageSource::cancellable`]). + #[error("cancelled")] + Cancelled, } pub type Result = std::result::Result; @@ -46,6 +52,7 @@ impl From for MediaError { match e { F::Unsupported(s) => MediaError::Unsupported(s), F::Io(s) | F::Offline(s) => MediaError::Io(s), + F::Cancelled => MediaError::Cancelled, other => MediaError::Decode(other.to_string()), } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 3f67b19b..98c70710 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -9,8 +9,9 @@ use effectcraft_raster::{Image, Px}; use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; +use image::metadata::Orientation; -use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled}; +use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; @@ -170,9 +171,10 @@ struct CacheState { last: HashMap, i64>, } -/// A movie/audio source by path, opened by the first caller while the others wait for it +/// A movie/audio source by path and the turn its picture is shown with +/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it /// (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +type SourceSlot = Arc>>; /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). type GifSlot = Arc, String>>>; @@ -186,7 +188,7 @@ struct Inner { files: Mutex>>, /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). models: Mutex>>>, - /// Decoded animated GIFs by path, loaded by the first caller while the others wait (an + /// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an /// `Err` is not retried until `forget`). gifs: Mutex, GifSlot>>, cache: Mutex, @@ -206,6 +208,9 @@ struct Inner { /// audio is read from the decoder instead of trying again on every read. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] conform_failed: Mutex>, + /// Movies that failed to open or decode (path to a readable reason; see + /// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not. + problems: Mutex, String>>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -275,6 +280,7 @@ impl MediaPool { conform: Mutex::default(), conforming: Mutex::default(), conform_failed: Mutex::default(), + problems: Mutex::default(), }), } } @@ -322,6 +328,7 @@ impl MediaPool { pub fn clear(&self) { self.clear_frames(); lock(&self.inner.sources).clear(); + lock(&self.inner.problems).clear(); } /// Forget a file (after it changed on disk, or to retry a file that failed to open). @@ -329,6 +336,7 @@ impl MediaPool { lock(&self.inner.sources).remove(path); lock(&self.inner.models).remove(path); lock(&self.inner.gifs).remove(path); + lock(&self.inner.problems).remove(path); let mut c = lock(&self.inner.cache); let keys: Vec = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect(); for k in keys { @@ -357,8 +365,13 @@ impl MediaPool { } match Inner::frame_at(&self.inner, footage, t, true, size, float) { Ok(img) => Some(img), + // nobody wants the frame any more (`cancellable`): not an error + Err(MediaError::Cancelled) => None, + // (a movie's failure is logged once per file, when it is recorded) Err(e) => { - log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); + if lock(&self.inner.problems).get(footage.path.as_str()).is_none() { + log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); + } None } } @@ -450,8 +463,8 @@ impl MediaPool { } /// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at - /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond - /// the first two are dropped. Silence where there is no audio (or before the media starts). + /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; multichannel + /// sources are downmixed to stereo ([`crate::downmix`]). Silence where there is no audio (or before the media starts). /// With a conformed audio folder, reads come from the conformed file once it is written. pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { if !footage.has_audio || footage.missing || rate == 0 || frames == 0 { @@ -574,17 +587,34 @@ impl Inner { /// it again: a preview's frame workers, audio feeder, conform and waveform threads each /// decoded a long FLAC in full at once (#408). fn source(&self, path: &Arc) -> Option { + self.opened(path).map(|(src, _)| src) + } + + /// [`Self::source`] and the turn the movie's picture is shown with. + fn opened(&self, path: &Arc) -> Option<(SharedSource, Orientation)> { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); - let opened = self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from) + // Files on disk are read in place (the index, then samples on demand) rather than + // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. + let in_memory = lock(&self.files).contains_key(&**path); + let file = std::path::Path::new(&**path); + let in_place = if in_memory { None } else { crate::stream::open_source(file) }; + let in_place = in_place.map(|src| { + let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); + src.map(|src| (src, turn)) + }); + let opened = in_place.unwrap_or_else(|| { + self.read(path).and_then(|bytes| { + let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let turn = crate::probe::video_turn(&bytes); + filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) + }) }); match opened { Ok(s) => Some(s), Err(e) => { - log::warn!("media: cannot open {path}: {e}"); + self.note_problem(path, "", &e); None } } @@ -592,7 +622,21 @@ impl Inner { .clone() } - /// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the + /// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known) + /// and log it the first time. I/O errors (missing or unreadable file) are not decoder + /// problems and are left to the missing-footage handling. + fn note_problem(&self, path: &Arc, codec: &str, e: &MediaError) { + // A decode called off for a newer frame (#329) says nothing about the file either. + if matches!(e, MediaError::Io(_) | MediaError::Cancelled) { + return; + } + let reason = describe_problem(codec, e); + if lock(&self.problems).insert(path.clone(), reason.clone()).is_none() { + log::warn!("media: {path}: {reason}"); + } + } + + /// The decoded frames of the animated GIF or WebP at `path`. The first caller decodes it, outside the /// map's lock; callers asking for the same file meanwhile wait for that. fn gif(&self, path: &Arc) -> Result> { let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); @@ -749,10 +793,7 @@ impl Inner { { return Err(MediaError::Decode(format!("{path}: {why}"))); } - let loaded = match crate::still_format(path, &bytes) { - Some(fmt) => image::load_from_memory_with_format(&bytes, fmt), - None => image::load_from_memory(&bytes), - }; + let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); let img = match loaded { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. @@ -760,25 +801,31 @@ impl Inner { }; Ok(dynamic_to_image(&img, op)) } - Some(mt) if gif_anim::is_gif(footage) => { + Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; Ok(dynamic_to_image(frame, op)) } Some(mt) => { - let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; - let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?; + let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; + let vf = src + .video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))) + .map_err(MediaError::from) + .inspect_err(|e| self.note_problem(path, &footage.codec, e))?; + // A movie shown turned a quarter (a phone held upright): the footage's size and + // the render's are the turned picture's, the decoded frame's are the file's. + let side = |(w, h): (u32, u32)| if crate::probe::swaps_sides(turn) { (h, w) } else { (w, h) }; // A reduced-resolution render's frame, made at its size (a frame of the footage's // own size only, as the renderer resamples only those). - let (w, h) = loc.key.size; + let (w, h) = side(loc.key.size); if w > 0 - && (vf.width, vf.height) == (footage.width, footage.height) + && (vf.width, vf.height) == side((footage.width, footage.height)) && let Some(img) = frame_to_image_scaled(&vf, w, h) { - return Ok(img); + return Ok(turned(img, turn)); } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); - Ok(frame_to_image_in(&vf, op, buf)) + Ok(turned(frame_to_image_in(&vf, op, buf), turn)) } } } @@ -806,6 +853,12 @@ impl FootageSource for MediaPool { fn cache_budget(&self) -> Option { Some(self.budget()) } + /// A movie's decoder gives up on a seek (before it, and between samples while it decodes + /// forward from the keyframe) once `cancel` is set: a scrub's superseded frames no longer + /// hold the decoder the frame under the pointer waits for (#329). + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + filmcraft_media::cancel::with_cancel(cancel, f) + } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } @@ -816,6 +869,13 @@ impl FootageSource for MediaPool { self.frame_sized(item, footage, t, size, float) } + fn problem(&self, footage: &Footage) -> Option { + if footage.missing || footage.kind != FootageKind::Video { + return None; + } + lock(&self.inner.problems).get(footage.path.as_str()).cloned() + } + fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { if footage.missing || !effectcraft_render::is_vector_footage(footage) { return None; @@ -846,7 +906,7 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels; silence before the start. An error where decoding fails. +/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); @@ -854,15 +914,28 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re return Ok(out); } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; - let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) }; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) { - *o0 = *a; - *o1 = *b; - } + let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); + crate::downmix::downmix_into(&buf.channels, aac, dst); Ok(out) } +/// The one-line reason a movie shows no picture: its `codec` (the probe's, e.g. "H.264 (High +/// 4:2:2)") and what the decoder said, without the error wrappers it passed through. +fn describe_problem(codec: &str, e: &MediaError) -> String { + let mut detail = match e { + MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s) => s.as_str(), + MediaError::Cancelled => "the decode was called off", + }; + let mut unsupported = matches!(e, MediaError::Unsupported(_)); + while let Some(rest) = ["decode error: ", "unsupported media: ", "unsupported: ", "I/O error: "].iter().find_map(|p| detail.strip_prefix(p)) { + unsupported |= detail.starts_with("unsupported"); + detail = rest; + } + let what = if codec.is_empty() { "This video" } else { codec }; + if unsupported { format!("{what} isn't supported by the built-in decoder: {detail}") } else { format!("{what} can't be decoded: {detail}") } +} + fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6853c3df..bae714c8 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use effectcraft_project::{AlphaMode, Footage, FootageKind}; use effectcraft_time::{FrameRate, Tick}; +use image::metadata::Orientation; use crate::{MediaError, Result, ext_of, rate_from_fc}; @@ -15,6 +16,13 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; /// (`effectcraft_engine::sequence`) and probes their first frame here. pub fn probe(path: impl AsRef) -> Result { let path = path.as_ref(); + // A movie is read in place (its index), not loaded whole. + if let Some(src) = crate::stream::probe_source(path) { + let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); + let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + turn_footage(&mut f, turn); + return Ok(f); + } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); @@ -77,11 +85,30 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { { return Ok(f); } + // So is an animated WebP; a still WebP stays a still. + if fmt == image::ImageFormat::WebP + && let Some(f) = crate::webp_anim::probe(path, &bytes) + { + return Ok(f); + } return probe_still_bytes(path, &bytes, fmt); } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let turn = video_turn(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; - Ok(footage_from_info(path, src.info())) + let mut f = footage_from_info(path, src.info()); + turn_footage(&mut f, turn); + Ok(f) +} + +/// The size as displayed: a movie shot with the camera on its side trades width and height. +fn turn_footage(f: &mut Footage, turn: Orientation) { + if swaps_sides(turn) { + std::mem::swap(&mut f.width, &mut f.height); + if f.pixel_aspect > 0.0 { + f.pixel_aspect = 1.0 / f.pixel_aspect; + } + } } pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage { @@ -171,7 +198,12 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { - Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), + Ok(mut dec) => { + // The size as displayed: an Exif orientation of a quarter turn swaps the sides. + let (w, h) = dec.dimensions(); + let (w, h) = if swaps_sides(dec.orientation().unwrap_or(Orientation::NoTransforms)) { (h, w) } else { (w, h) }; + (w, h, dec.color_type().has_alpha()) + } Err(e) => { // A multi-layer OpenEXR file without an unnamed RGB layer. let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten(); @@ -181,3 +213,44 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul }; Ok(still_footage(path, w, h, fmt, has_alpha)) } + +/// Whether an Exif orientation turns the picture a quarter turn (its width and height trade places). +pub(crate) fn swaps_sides(o: Orientation) -> bool { + matches!(o, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) +} + +/// Decode a still the way it is meant to be seen: its Exif orientation (a camera held sideways, a +/// mirrored scan) is applied to the pixels, as image viewers do. A file without +/// the tag, or with a broken one, decodes as stored. `fmt`: the format when the file's extension +/// decides it (Targa has no signature to guess from), else guessed from the bytes. +pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> image::ImageResult { + use image::ImageDecoder; + let reader = match fmt { + Some(fmt) => image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt), + None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?, + }; + let mut dec = reader.into_decoder()?; + let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms); + let mut img = image::DynamicImage::from_decoder(dec)?; + img.apply_orientation(orientation); + Ok(img) +} + +/// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and +/// says so in its video track's header): the quarter turn nearest the track's rotation. Only the +/// rotation counts; any mirroring, scaling or offset in the header is ignored, and other +/// containers have none. +pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { + let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; + let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + return Orientation::NoTransforms; + }; + // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. + let (a, b) = (m[0] as i64, m[1] as i64); + match (a.abs() >= b.abs(), a >= 0, b >= 0) { + (true, true, _) => Orientation::NoTransforms, + (true, false, _) => Orientation::Rotate180, + (false, _, true) => Orientation::Rotate90, + (false, _, false) => Orientation::Rotate270, + } +} diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs new file mode 100644 index 00000000..447e239d --- /dev/null +++ b/crates/media/src/stream.rs @@ -0,0 +1,162 @@ +//! Movie and audio files read in place: the container's index now, single samples on demand. +//! +//! Reading a file whole before opening it made a clip cost its full size in memory (twice while +//! probing: the file's bytes plus the demuxer's copy), so a 45-minute movie took gigabytes just to +//! import. FilmCraft's reader openers (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG-PS/TS) open from a +//! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file. +//! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before. +//! Builds without a native file system (wasm32) keep using in-memory bytes. + +use std::path::Path; + +use filmcraft_media::SharedSource; + +use crate::Result; + +/// A file on disk read in place: it stays open and only the requested ranges are read. +#[cfg(any(unix, windows))] +#[derive(Debug)] +pub(crate) struct FileReader { + file: std::fs::File, + len: u64, +} + +#[cfg(any(unix, windows))] +impl FileReader { + pub(crate) fn open(path: &Path) -> std::io::Result { + let file = std::fs::File::open(path)?; + let m = file.metadata()?; + if !m.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); + } + Ok(Self { file, len: m.len() }) + } +} + +#[cfg(any(unix, windows))] +impl filmcraft_media::ByteReader for FileReader { + fn len(&self) -> u64 { + self.len + } + + /// Positional reads: threads decoding different parts of the file share no cursor. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset) + } + #[cfg(windows)] + { + let (mut done, mut at) = (0usize, offset); + while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) { + match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) { + Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")), + Ok(n) => { + done = done.saturating_add(n); + at = at.saturating_add(n as u64); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) + } + } +} + +#[cfg(any(unix, windows))] +fn name_of(path: &Path) -> String { + path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default() +} + +/// Open a movie/audio file for the media pool, reading only what its container needs; files the +/// reader openers don't handle are read whole and opened from memory, exactly as before. `None` +/// when the file can't be opened in place (the caller reads it whole, which reports the error). +#[cfg(any(unix, windows))] +pub(crate) fn open_source(path: &Path) -> Option> { + let reader = FileReader::open(path).ok()?; + let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); + let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()); + Some(opened.map_err(Into::into)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_source(_path: &Path) -> Option> { + None +} + +/// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display +/// matrix needs), without reading the samples: top-level box headers are walked with positional +/// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large. +#[cfg(any(unix, windows))] +pub(crate) fn movie_header(path: &Path) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + const MAX_MOOV: u64 = 64 << 20; + let mut f = std::fs::File::open(path).ok()?; + let len = f.metadata().ok()?.len(); + let mut out = Vec::new(); + let mut pos = 0u64; + for _ in 0..4096 { + if pos.checked_add(8)? > len { + return None; + } + f.seek(SeekFrom::Start(pos)).ok()?; + let mut h = [0u8; 16]; + f.read_exact(&mut h[..8]).ok()?; + let kind = [h[4], h[5], h[6], h[7]]; + let mut size = u64::from(u32::from_be_bytes([h[0], h[1], h[2], h[3]])); + if size == 1 { + f.read_exact(&mut h[8..16]).ok()?; + size = u64::from_be_bytes([h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]]); + } else if size == 0 { + size = len - pos; + } + if size < 8 || pos.checked_add(size)? > len { + return None; + } + if kind == *b"ftyp" || kind == *b"moov" { + if size > MAX_MOOV { + return None; + } + f.seek(SeekFrom::Start(pos)).ok()?; + let mut b = vec![0u8; usize::try_from(size).ok()?]; + f.read_exact(&mut b).ok()?; + out.extend_from_slice(&b); + if kind == *b"moov" { + return Some(out); + } + } + pos += size; + } + None +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn movie_header(_path: &Path) -> Option> { + None +} + +/// Probing in place: the opened container when `path` is a movie/audio container a reader opener +/// handles and nothing probed before containers (stills, layered and vector documents, 3D models) +/// claims it. `None`: probe the whole file's bytes as before. +#[cfg(any(unix, windows))] +pub(crate) fn probe_source(path: &Path) -> Option> { + let ext = crate::ext_of(path); + if crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::MODEL_EXTENSIONS.contains(&ext.as_str()) { + return None; + } + let reader = FileReader::open(path).ok()?; + let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); + let head = filmcraft_media::reader::read_range(&*reader, 0, filmcraft_media::reader::HEAD_LEN).ok()?; + let name = path.to_string_lossy(); + if crate::layered::sniff(&name, &head) || image::guess_format(&head).is_ok() { + return None; + } + let name = name_of(path); + filmcraft_codecs::reader_openers().into_iter().find_map(|open| open(&name, &head, &reader)).map(|r| r.map_err(Into::into)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn probe_source(_path: &Path) -> Option> { + None +} diff --git a/crates/media/src/webp_anim.rs b/crates/media/src/webp_anim.rs new file mode 100644 index 00000000..9420be1e --- /dev/null +++ b/crates/media/src/webp_anim.rs @@ -0,0 +1,329 @@ +//! Animated WebP: probed as movies and decoded whole into 8-bit frames, like animated GIF +//! ([`crate::gif_anim`], whose [`Anim`] holds the frames). +//! +//! An animated WebP is a RIFF file with a `VP8X` header whose animation flag is set and one `ANMF` +//! chunk per frame, each with its own duration in milliseconds. Evenly spaced frames get the rate +//! they are spaced at. Encoders round the frame times of a rate like 24 or 29.97 frames a second to +//! whole milliseconds (41, 42, 42, 41… ms), so delays that differ by at most one millisecond are +//! read as the standard rate whose frame times they round to; any other timing gets the rate that +//! every delay is a whole number of frames at (1000 / the greatest common divisor of the delays), +//! so it is kept exactly. A still WebP (no animation flag, or a single frame) stays a still. + +use std::io::Cursor; + +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::{FrameRate, Tick}; +use image::{AnimationDecoder, DynamicImage, codecs::webp::WebPDecoder}; + +use crate::gif_anim::{Anim, MAX_BYTES, MAX_FRAMES, gcd}; +use crate::{MediaError, Result}; + +/// The `Footage::codec` of an animated WebP. +pub(crate) const CODEC: &str = "WebP"; + +/// Whether `bytes` start like a WebP file. +pub(crate) fn is_webp(bytes: &[u8]) -> bool { + bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") +} + +/// What the chunk headers of an animated WebP say. +struct Header { + width: u32, + height: u32, + alpha: bool, + /// Each frame's delay in milliseconds, as stored. + delays: Vec, +} + +fn u24(b: &[u8]) -> Option { + match b { + [a, b, c, ..] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16), + _ => None, + } +} + +/// Read the `VP8X` header and the `ANMF` frame headers. `None` when the file isn't an animated +/// WebP. A truncated tail keeps the frames before it. +fn header(bytes: &[u8]) -> Option
{ + if !is_webp(bytes) { + return None; + } + let riff = bytes.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; + let end = usize::try_from(riff).ok()?.saturating_add(8).min(bytes.len()); + let (mut at, mut canvas, mut delays) = (12usize, None, Vec::new()); + while delays.len() < MAX_FRAMES { + let Some(head) = bytes.get(at..at.saturating_add(8)).filter(|_| at.saturating_add(8) <= end) else { break }; + let size = head.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; + let size = usize::try_from(size).ok()?; + let body_end = at.saturating_add(8).saturating_add(size); + let Some(body) = bytes.get(at.saturating_add(8)..body_end).filter(|_| body_end <= end) else { break }; + match head.get(0..4) { + Some(b"VP8X") => { + let flags = *body.first()?; + if flags & 0x02 == 0 { + // No animation flag: a still. + return None; + } + let w = u24(body.get(4..)?)?.saturating_add(1); + let h = u24(body.get(7..)?)?.saturating_add(1); + canvas = Some((w, h, flags & 0x10 != 0)); + } + Some(b"ANMF") if body.len() >= 16 => delays.push(u24(body.get(12..)?)?), + _ => {} + } + at = body_end.saturating_add(size & 1); + } + let (width, height, alpha) = canvas?; + Some(Header { width, height, alpha, delays }) +} + +/// The delay of a frame in milliseconds. Browsers show delays of 10 ms or less (that encoders +/// write for "as fast as possible") as 100 ms, and so do we, as for GIF. +fn delay_ms(raw: u32) -> u32 { + if raw <= 10 { 100 } else { raw } +} + +/// The footage rate, when each frame starts, and the duration, for frames of these delays (ms). +struct Timing { + rate: FrameRate, + starts: Vec, + duration: Tick, +} + +fn timing(delays: &[u32]) -> Timing { + let delays: Vec = delays.iter().map(|&d| delay_ms(d)).collect(); + let mut starts_ms = Vec::with_capacity(delays.len()); + let mut total = 0i64; + for &d in &delays { + starts_ms.push(total); + total = total.saturating_add(i64::from(d)); + } + let n = i64::try_from(delays.len()).unwrap_or(i64::MAX); + let (min, max) = (delays.iter().copied().min().unwrap_or(0), delays.iter().copied().max().unwrap_or(0)); + // Frame times of a standard rate rounded to milliseconds: that rate if every frame starts (and + // the last one ends) within a millisecond of it. + let fps = if total > 0 { n.saturating_mul(1000).saturating_add(total / 2) / total } else { 0 }; + if max == min.saturating_add(1) && fps > 0 { + for rate in [FrameRate::new(fps, 1), FrameRate::new(fps.saturating_mul(1000), 1001)] { + // |start_ms - k * 1000 * den / num| <= 1, in integers. + let (num, den) = (i128::from(rate.num), i128::from(rate.den)); + let fits = starts_ms.iter().copied().chain([total]).enumerate().all(|(k, ms)| { + let k = i128::try_from(k).unwrap_or(i128::MAX); + (i128::from(ms) * num - k * 1000 * den).abs() <= num + }); + if fits { + let starts = (0..n).map(|k| rate.tick_of(k)).collect(); + return Timing { rate, starts, duration: rate.tick_of(n) }; + } + } + } + let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1); + Timing { + rate: FrameRate::new(1000, i64::from(step)), + starts: starts_ms.iter().map(|&ms| Tick::from_units(ms, 1000)).collect(), + duration: Tick::from_units(total, 1000), + } +} + +/// Describe an animated WebP as a movie. `None` for a still WebP (no animation, or one frame) or +/// one whose headers can't be read. +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { + let h = header(bytes)?; + if h.delays.len() < 2 || h.width == 0 || h.height == 0 { + return None; + } + let t = timing(&h.delays); + Some(Footage { + path: path.to_string(), + kind: FootageKind::Video, + width: h.width, + height: h.height, + frame_rate: t.rate, + duration: t.duration, + has_video: true, + has_audio: false, + alpha: if h.alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, + codec: CODEC.to_string(), + ..Default::default() + }) +} + +/// Decode every frame of an animated WebP, composited onto the full canvas. A damaged tail keeps +/// the frames before it; an animation too large to hold in memory is refused before decoding. +pub(crate) fn load(bytes: &[u8]) -> Result { + let h = header(bytes).ok_or_else(|| MediaError::Decode("WebP: not an animated WebP".into()))?; + let frame_bytes = usize::try_from(u64::from(h.width) * u64::from(h.height) * 4).unwrap_or(usize::MAX); + if frame_bytes.saturating_mul(h.delays.len()) > MAX_BYTES { + return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height))); + } + let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?; + let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize); + for frame in dec.into_frames() { + let frame = match frame { + Ok(f) => f, + Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("WebP: {e}"))), + Err(e) => { + log::warn!("media: WebP stops after {} frames: {e}", frames.len()); + break; + } + }; + let (numer, denom) = frame.delay().numer_denom_ms(); + delays.push(numer.checked_div(denom).unwrap_or(0)); + let buf = frame.into_buffer(); + size = size.saturating_add(buf.as_raw().len()); + if size > MAX_BYTES { + return Err(MediaError::Unsupported("animated WebP is too large to hold in memory".into())); + } + frames.push(DynamicImage::ImageRgba8(buf)); + } + let starts = timing(&delays).starts; + Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("WebP: no frames".into())) +} + +#[cfg(test)] +mod tests { + use effectcraft_project::{AlphaMode, Footage, FootageKind}; + use effectcraft_time::{FrameRate, Tick}; + use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; + + use super::timing; + use crate::{MediaPool, probe_bytes}; + + fn chunk(fourcc: &[u8; 4], body: &[u8]) -> Vec { + let mut out = fourcc.to_vec(); + out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } + out + } + + fn u24(v: u32) -> [u8; 3] { + let b = v.to_le_bytes(); + [b[0], b[1], b[2]] + } + + /// The `VP8L` chunk of a lossless still WebP of a solid RGBA colour. + fn vp8l(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut still = Vec::new(); + let px = rgba.repeat((w * h) as usize); + WebPEncoder::new_lossless(&mut still).write_image(&px, w, h, ExtendedColorType::Rgba8).unwrap(); + let at = still.windows(4).position(|c| c == b"VP8L").unwrap(); + still[at..].to_vec() + } + + /// A `w`×`h` animated WebP with alpha, one solid full-canvas frame per `(rgba, delay ms)`. + fn webp(w: u32, h: u32, frames: &[([u8; 4], u32)]) -> Vec { + let mut vp8x = vec![0x12, 0, 0, 0]; + vp8x.extend_from_slice(&u24(w - 1)); + vp8x.extend_from_slice(&u24(h - 1)); + let mut body = b"WEBP".to_vec(); + body.extend(chunk(b"VP8X", &vp8x)); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for (rgba, delay) in frames { + let mut anmf = Vec::new(); + anmf.extend_from_slice(&[0; 6]); + anmf.extend_from_slice(&u24(w - 1)); + anmf.extend_from_slice(&u24(h - 1)); + anmf.extend_from_slice(&u24(*delay)); + anmf.push(0b10); // no blending, no disposal + anmf.extend(vp8l(w, h, *rgba)); + body.extend(chunk(b"ANMF", &anmf)); + } + chunk(b"RIFF", &body) + } + + const RED: [u8; 4] = [255, 0, 0, 255]; + const GREEN: [u8; 4] = [0, 255, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 128]; + + fn pixel_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 4] { + pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap().data[0] + } + + #[test] + fn an_animated_webp_is_a_movie_with_its_frames() { + let bytes = webp(4, 2, &[(RED, 50), (GREEN, 100), (BLUE, 50)]); + let f = probe_bytes("a.webp", bytes.clone().into()).unwrap(); + assert_eq!(f.kind, FootageKind::Video); + assert!(f.has_video && !f.has_audio); + assert_eq!((f.width, f.height), (4, 2)); + assert_eq!(f.alpha, AlphaMode::Straight); + // Delays of 50, 100 and 50 ms: 20 frames a second, 0.2 s in all. + assert_eq!(f.frame_rate, FrameRate::new(20, 1)); + assert_eq!(f.duration, Tick::from_units(200, 1000)); + + let pool = MediaPool::new(); + pool.add_bytes("a.webp", bytes.into()); + let first = pixel_at(&pool, &f, 0.01); + assert_eq!(&first[..3], &[1.0, 0.0, 0.0]); + let second = pixel_at(&pool, &f, 0.06); + assert_ne!(first, second, "a later frame differs from the first"); + assert_eq!(&second[..3], &[0.0, 1.0, 0.0]); + assert_eq!(&pixel_at(&pool, &f, 0.14)[..3], &[0.0, 1.0, 0.0]); + // The last frame keeps its alpha, and holds past the end. + let last = pixel_at(&pool, &f, 0.17); + assert!((last[3] - 128.0 / 255.0).abs() < 1e-3, "{last:?}"); + assert_eq!(pixel_at(&pool, &f, 5.0), last); + } + + #[test] + fn delays_rounded_to_milliseconds_read_as_the_standard_rate() { + // 24 frames a second rounded to whole milliseconds, as ffmpeg writes them. + let delays: Vec = (0..24).map(|k| (((k + 1) * 1000 + 12) / 24 - (k * 1000 + 12) / 24) as u32).collect(); + assert!(delays.iter().all(|&d| d == 41 || d == 42)); + let t = timing(&delays); + assert_eq!(t.rate, FrameRate::new(24, 1)); + assert_eq!(t.duration, Tick::from_seconds_f64(1.0)); + assert_eq!(t.starts[5], FrameRate::new(24, 1).tick_of(5)); + // 29.97: 33 and 34 ms over a long run. + let ntsc = FrameRate::new(30_000, 1001); + let delays: Vec = (0..300).map(|k| (((k + 1) * 1_001_000 / 30_000) - (k * 1_001_000 / 30_000)) as u32).collect(); + assert_eq!(timing(&delays).rate, ntsc); + // Uneven timing is kept exactly. + assert_eq!(timing(&[40, 80, 40]).rate, FrameRate::new(25, 1)); + assert_eq!(timing(&[33, 34, 50]).rate, FrameRate::new(1000, 1)); + // As fast as possible is 100 ms, as in browsers. + assert_eq!(timing(&[0, 10]).duration, Tick::from_units(200, 1000)); + } + + #[test] + fn a_still_webp_stays_a_still() { + let mut still = Vec::new(); + WebPEncoder::new_lossless(&mut still).write_image(&RED.repeat(4), 2, 2, ExtendedColorType::Rgba8).unwrap(); + let f = probe_bytes("s.webp", still.into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + // So does an animation of one frame. + let f = probe_bytes("one.webp", webp(2, 2, &[(RED, 100)]).into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + } + + #[test] + fn a_truncated_animated_webp_is_an_error_not_a_panic() { + let full = webp(4, 2, &[(RED, 50), (GREEN, 50), (BLUE, 50)]); + let f = probe_bytes("a.webp", full.clone().into()).unwrap(); + for len in [0, 11, 20, 40, full.len() / 2, full.len() - 3] { + let bytes = &full[..len]; + let _ = probe_bytes("t.webp", bytes.to_vec().into()); + let pool = MediaPool::new(); + pool.add_bytes("a.webp", bytes.to_vec().into()); + // Cut before the second frame's data: no frames at all, or the ones before the cut. + let r = pool.frame_at(&f, Tick::ZERO); + if len < full.len() / 2 { + assert!(r.is_err(), "{len} bytes"); + } + } + } + + #[test] + fn a_huge_animation_is_refused_before_decoding() { + // 16384×16384 canvas, 10 frames: 10 GiB of RGBA. The frame data is a tiny 1×1 picture; + // the size check comes first. + let mut bytes = webp(1, 1, &[(RED, 50); 10]); + bytes[24..27].copy_from_slice(&u24(16383)); + bytes[27..30].copy_from_slice(&u24(16383)); + let e = super::load(&bytes).err().unwrap(); + assert!(e.to_string().contains("too large"), "{e}"); + } +} diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/downmix.rs new file mode 100644 index 00000000..e0e7d225 --- /dev/null +++ b/crates/media/tests/downmix.rs @@ -0,0 +1,226 @@ +//! Multichannel audio downmix (#903) against ffmpeg as an external oracle. Fixtures: ffmpeg with +//! one sine per channel, written to `target/fixtures/media`; tests skip when ffmpeg is missing. + +use std::f64::consts::PI; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use effectcraft_media::{MediaPool, probe}; +use effectcraft_time::Tick; + +const RATE: u32 = 48_000; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") +} + +/// A fixture with `layout`'s channels `names` (join order) each carrying its own sine, encoded +/// with `codec_args`. `None` when ffmpeg is missing or cannot make it. +fn fixture(name: &str, layout: &str, names: &[&str], freqs: &[u32], codec_args: &[&str]) -> Option { + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + eprintln!("ffmpeg not found: skipping"); + return None; + } + let out = fixtures().join(name); + if out.exists() { + return Some(out); + } + std::fs::create_dir_all(fixtures()).ok()?; + let ext = out.extension()?.to_string_lossy().to_string(); + let tmp = out.with_file_name(format!("tmp-{}-{name}", std::process::id())).with_extension(ext); + let mut cmd = Command::new("ffmpeg"); + cmd.args(["-hide_banner", "-loglevel", "error", "-y"]); + let mut map = String::new(); + for (i, (n, f)) in names.iter().zip(freqs).enumerate() { + cmd.args(["-f", "lavfi", "-i", &format!("sine=frequency={f}:sample_rate={RATE}:duration=2")]); + map.push_str(&format!("[{i}]")); + let _ = n; + } + let post = if name.contains("loud") { ",volume=8:precision=float,alimiter=limit=1:level=disabled" } else { "" }; + let joins: Vec = names.iter().enumerate().map(|(i, n)| format!("{i}.0-{n}")).collect(); + cmd.args(["-filter_complex", &format!("{map}join=inputs={}:channel_layout={layout}:map={}{}[a]", names.len(), joins.join("|"), post), "-map", "[a]"]); + cmd.args(codec_args).arg(&tmp); + let ok = cmd.status().ok()?.success(); + if !ok { + eprintln!("ffmpeg could not make {name}"); + return None; + } + std::fs::rename(&tmp, &out).ok()?; + Some(out) +} + +/// Amplitude (peak) of the sine at `freq` in one channel of interleaved stereo. +fn amp(s: &[f32], ch: usize, freq: u32) -> f64 { + let (mut re, mut im, mut n) = (0.0, 0.0, 0usize); + for (i, f) in s.as_chunks::<2>().0.iter().enumerate() { + let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE); + re += f64::from(f[ch]) * w.cos(); + im += f64::from(f[ch]) * w.sin(); + n += 1; + } + 2.0 * (re * re + im * im).sqrt() / n.max(1) as f64 +} + +const K: f64 = std::f64::consts::FRAC_1_SQRT_2; + +struct Case { + file: &'static str, + layout: &'static str, + names: &'static [&'static str], + codec: &'static [&'static str], + /// Expected `[left, right]` gain of each channel (the same order as `names`). + gains: Vec<[f64; 2]>, + /// Allowed deviation of a channel's amplitude (lossy codecs). + tol: f64, +} + +const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700]; + +fn five_one() -> (&'static [&'static str], Vec<[f64; 2]>) { + let k = 1.0 / (1.0 + 2.0 * K); + (&["FL", "FR", "FC", "LFE", "BL", "BR"], vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K]]) +} + +fn seven_one() -> (&'static [&'static str], Vec<[f64; 2]>) { + let k = 1.0 / (1.0 + 3.0 * K); + ( + &["FL", "FR", "FC", "LFE", "BL", "BR", "SL", "SR"], + vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K], [k * K, 0.0], [0.0, k * K]], + ) +} + +fn cases() -> Vec { + let (n51, g51) = five_one(); + let (n71, g71) = seven_one(); + let k5 = 1.0 / (1.0 + 2.0 * K); + let kq = 1.0 / (1.0 + K); + vec![ + Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, + Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, + Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, + Case { file: "dm71.wav", layout: "7.1", names: n71, codec: &["-c:a", "pcm_s16le"], gains: g71.clone(), tol: 0.02 }, + Case { file: "dm71.flac", layout: "7.1", names: n71, codec: &["-c:a", "flac"], gains: g71, tol: 0.02 }, + Case { + file: "dm50.wav", + layout: "5.0", + names: &["FL", "FR", "FC", "BL", "BR"], + codec: &["-c:a", "pcm_s16le"], + gains: vec![[k5, 0.0], [0.0, k5], [k5 * K, k5 * K], [k5 * K, 0.0], [0.0, k5 * K]], + tol: 0.02, + }, + Case { + file: "dmquad.wav", + layout: "quad", + names: &["FL", "FR", "BL", "BR"], + codec: &["-c:a", "pcm_s16le"], + gains: vec![[kq, 0.0], [0.0, kq], [kq * K, 0.0], [0.0, kq * K]], + tol: 0.02, + }, + ] +} + +/// Our stereo mix of `p` over one second, from 0.2 s in. +fn ours(p: &Path) -> Vec { + let f = probe(p).expect("probe"); + MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.2), RATE as usize, RATE) +} + +/// ffmpeg's own `-ac 2` downmix of the same second, interleaved. Written as 16-bit integers: +/// swresample only normalises the matrix (to keep full scale from clipping) for integer output. +fn ffmpeg_ac2(p: &Path) -> Vec { + let out = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-ss", "0.2", "-t", "1", "-i"]) + .arg(p) + .args(["-ac", "2", "-ar", "48000", "-f", "s16le", "-"]) + .output() + .expect("run ffmpeg"); + out.stdout.as_chunks::<2>().0.iter().map(|b| f32::from(i16::from_le_bytes(*b)) / 32768.0).collect() +} + +fn rms(s: &[f32]) -> f64 { + (s.iter().map(|v| f64::from(*v).powi(2)).sum::() / s.len().max(1) as f64).sqrt() +} + +fn peak(s: &[f32]) -> f64 { + s.iter().fold(0.0f64, |m, v| m.max(f64::from(v.abs()))) +} + +/// Each channel carries its own sine at amplitude 0.5·√... (ffmpeg's `sine` is −24 dBFS RMS, +/// peak ≈ 0.125). Every channel must reach each side at the BS.775 gain, and the overall level +/// must match ffmpeg's `-ac 2`. +#[test] +fn each_channel_reaches_stereo_at_its_bs775_gain() { + for c in cases() { + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue }; + let s = ours(&p); + let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) + for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { + for (side, gain) in g.iter().enumerate() { + let got = amp(&s, side, freqs[i]); + let want = sine * gain; + assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); + } + } + let theirs = ffmpeg_ac2(&p); + let (a, b) = (rms(&s), rms(&theirs)); + let db = 20.0 * (a / b).log10(); + assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); + let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); + assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); + } +} + +/// Full-scale in every channel must not clip. +#[test] +fn loud_surround_does_not_clip() { + let Some(p) = fixture("dm51_loud.wav", "5.1", &["FL", "FR", "FC", "LFE", "BL", "BR"], &[300; 6], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!(peak(&s) <= 1.001, "peak {}", peak(&s)); + assert!(peak(&s) > 0.5, "audible, peak {}", peak(&s)); +} + +/// Mono and stereo footage keep their level (mono on both sides). +#[test] +fn mono_and_stereo_keep_their_level() { + let Some(p) = fixture("dm10.wav", "mono", &["FC"], &[440], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && (amp(&s, 1, 440) - 0.125).abs() < 0.003); + let Some(p) = fixture("dm20.wav", "stereo", &["FL", "FR"], &[440, 660], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && amp(&s, 1, 440) < 0.003); + assert!((amp(&s, 1, 660) - 0.125).abs() < 0.003 && amp(&s, 0, 660) < 0.003); +} + +/// Odd channel counts (3, 4, 7, 9, 12, 16) decode to finite stereo without panicking. +#[test] +fn hostile_channel_counts_do_not_panic() { + for (n, layout) in [(3, "3.0"), (4, "quad"), (7, "6.1"), (9, "9 channels"), (12, "12 channels"), (16, "16 channels")] { + let names: Vec = (0..n).map(|i| format!("{i}")).collect(); + // channel names for unnamed layouts are DEFAULT-ordered by position + let _ = names; + let file = format!("dmn{n}.wav"); + let args = ["-f", "lavfi", "-i", &format!("sine=frequency=440:sample_rate={RATE}:duration=1")]; + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + return; + } + let out = fixtures().join(&file); + std::fs::create_dir_all(fixtures()).expect("mkdir"); + if !out.exists() { + let tmp = fixtures().join(format!("tmp-{}-{file}", std::process::id())); + let af = format!("pan={layout}|{}", (0..n).map(|i| format!("c{i}=c0")).collect::>().join("|")); + let ok = + Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).args(["-af", &af, "-c:a", "pcm_s16le"]).arg(&tmp).status(); + if !ok.is_ok_and(|s| s.success()) { + eprintln!("ffmpeg cannot make {n} channels: skipping"); + continue; + } + std::fs::rename(&tmp, &out).expect("rename"); + } + let Ok(f) = probe(&out) else { continue }; + let s = MediaPool::new().audio_samples(&f, Tick::ZERO, 4800, RATE); + assert_eq!(s.len(), 9600, "{n} channels"); + assert!(s.iter().all(|v| v.is_finite() && v.abs() <= 1.001), "{n} channels"); + } +} diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/exif_orientation.rs new file mode 100644 index 00000000..d5acc3c2 --- /dev/null +++ b/crates/media/tests/exif_orientation.rs @@ -0,0 +1,84 @@ +//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the +//! footage has the size the picture is seen at, and its frames are the picture the right way up. + +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::FootageKind; +use effectcraft_time::Tick; + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const GREEN: [f32; 3] = [0.0, 1.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; +const WHITE: [f32; 3] = [1.0, 1.0, 1.0]; + +/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag` +/// (`None`: no Exif at all). +fn quartered_jpeg(tag: Option) -> Vec { + let img = image::RgbImage::from_fn(32, 16, |x, y| { + let c = match (x < 16, y < 8) { + (true, true) => RED, + (false, true) => GREEN, + (true, false) => BLUE, + (false, false) => WHITE, + }; + image::Rgb(c.map(|v| (v * 255.0) as u8)) + }); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode"); + let Some(tag) = tag else { return jpeg }; + // APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT). + let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22]; + app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08"); + app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]); + app1.extend_from_slice(&[0x00; 4]); + // (Straight after the SOI marker.) + jpeg.splice(2..2, app1); + jpeg +} + +/// The footage size and the colours of its four corners (top-left, top-right, bottom-left, +/// bottom-right), as imported. +fn imported(tag: Option) -> ((u32, u32), [[f32; 3]; 4]) { + let name = format!("oriented-{tag:?}.jpg"); + let bytes: Arc<[u8]> = quartered_jpeg(tag).into(); + let f = probe_bytes(&name, bytes.clone()).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + let pool = MediaPool::new(); + pool.add_bytes(&name, bytes); + let img = pool.frame_at(&f, Tick::ZERO).expect("frame"); + assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size"); + let (w, h) = (img.width as usize, img.height as usize); + let at = |x: usize, y: usize| { + let p = img.data[y * w + x]; + [p[0], p[1], p[2]] + }; + ((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)]) +} + +#[test] +fn exif_orientation_is_applied_to_stills() { + let (landscape, portrait) = ((32, 16), (16, 32)); + let cases = [ + (None, landscape, [RED, GREEN, BLUE, WHITE]), + (Some(1), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(2), landscape, [GREEN, RED, WHITE, BLUE]), + (Some(3), landscape, [WHITE, BLUE, GREEN, RED]), + (Some(4), landscape, [BLUE, WHITE, RED, GREEN]), + (Some(5), portrait, [RED, BLUE, GREEN, WHITE]), + (Some(6), portrait, [BLUE, RED, WHITE, GREEN]), + (Some(7), portrait, [WHITE, GREEN, BLUE, RED]), + (Some(8), portrait, [GREEN, WHITE, RED, BLUE]), + // (Not an orientation: the picture as stored.) + (Some(0), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(9), landscape, [RED, GREEN, BLUE, WHITE]), + ]; + for (tag, size, corners) in cases { + let (got_size, got) = imported(tag); + assert_eq!(got_size, size, "orientation {tag:?}: size"); + for (i, (g, want)) in got.iter().zip(corners).enumerate() { + let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2); + assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}"); + } + } +} diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 58a6ecf2..13ca605e 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -9,7 +9,7 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -use effectcraft_media::{MediaPool, probe}; +use effectcraft_media::{MediaPool, probe, probe_bytes}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::Image; use effectcraft_render::FootageSource; @@ -336,6 +336,59 @@ fn resampled_audio_read_in_frame_pieces_matches_ffmpeg() { } } +/// The H.264 fixture with the rotation part of its video track header's matrix (`[a b; c d]`, +/// 16.16 fixed point) replaced: what a phone writes for a movie shot with the camera turned. +fn turned_mp4(p: &Path, abcd: [i32; 4]) -> Arc<[u8]> { + let mut bytes = std::fs::read(p).expect("read"); + // (The video track comes first; a version 0 `tkhd` has its matrix 44 bytes after the type.) + let at = bytes.windows(4).position(|w| w == b"tkhd").expect("tkhd"); + assert_eq!(bytes[at + 4], 0, "a version 0 tkhd"); + for (slot, v) in [0, 1, 3, 4].into_iter().zip(abcd) { + let o = at + 44 + slot * 4; + bytes[o..o + 4].copy_from_slice(&v.to_be_bytes()); + } + bytes.into() +} + +/// A movie whose track header says it is shown rotated (a phone held upright) imports the way it +/// is shown: the footage has the turned size and its frames are turned, at full and reduced size. +#[test] +fn rotated_mp4_imports_upright() { + let Some(p) = h264_mp4() else { return }; + let plain_footage = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let t = t_of(plain_footage.frame_rate, 3); + let plain = pool.frame(ItemId(1), &plain_footage, t).expect("frame"); + let (w, h) = (plain.width as usize, plain.height as usize); + let one = 0x1_0000; + // The matrix, the turned size and the plain frame's pixel shown at (x, y). + type From = fn(usize, usize, usize, usize) -> (usize, usize); + let cases: [(&str, [i32; 4], (usize, usize), From); 3] = [ + ("90", [0, one, -one, 0], (h, w), |x, y, _, h| (y, h - 1 - x)), + ("180", [-one, 0, 0, -one], (w, h), |x, y, w, h| (w - 1 - x, h - 1 - y)), + ("270", [0, -one, one, 0], (h, w), |x, y, w, _| (w - 1 - y, x)), + ]; + for (name, matrix, (tw, th), from) in cases { + let path = format!("turned-{name}.mp4"); + let bytes = turned_mp4(&p, matrix); + let f = probe_bytes(&path, bytes.clone()).expect("probe"); + assert_eq!((f.width as usize, f.height as usize), (tw, th), "{name}°: size"); + pool.add_bytes(&path, bytes); + let img = pool.frame(ItemId(2), &f, t).expect("frame"); + assert_eq!((img.width as usize, img.height as usize), (tw, th), "{name}°: frame size"); + for (x, y) in [(0, 0), (tw - 1, 0), (0, th - 1), (tw - 1, th - 1), (tw / 3, th / 5), (tw / 2, th - 7)] { + let (sx, sy) = from(x, y, w, h); + assert_eq!(img.data[y * tw + x], plain.data[sy * w + sx], "{name}°: pixel ({x}, {y})"); + } + // (A reduced-resolution render asks for the turned size.) + let (hw, hh) = (f.width / 2, f.height / 2); + let sized = pool.frame_at_size(ItemId(2), &f, t, hw, hh).expect("frame"); + assert_eq!((sized.width, sized.height), (hw, hh), "{name}°: reduced size"); + let db = psnr(&sized, &effectcraft_raster::resample(&img, hw, hh)); + assert!(db > 45.0, "{name}° at half size: {db:.2} dB"); + } +} + #[test] fn prores_4444_with_alpha() { let Some(p) = prores_mov() else { return }; @@ -778,9 +831,14 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { } at += 1024; } - // ffmpeg's sine source has an amplitude of 1/8, on both channels (mono is duplicated). + // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries + // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as + // its stereo downmix (tests/downmix.rs checks that matrix channel by channel). + let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); + let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); + let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); let rms = (sum / n.max(1) as f64).sqrt(); - assert!((rms - 0.125 / 2f64.sqrt()).abs() < 0.003, "{}: rms {rms}", p.display()); + assert!((rms - want).abs() < 0.003, "{}: rms {rms}, expected {want}", p.display()); eprintln!("{}: read {secs} s in {:?}, slowest block after the first {worst:?}", p.display(), t0.elapsed()); assert_eq!(pool.stats().opened, 1, "opened once, not per block"); } @@ -890,7 +948,9 @@ fn dpx_stills_match_ffmpeg() { assert!(effectcraft_media::is_importable(&p)); let f = probe(&p).expect(pf); assert_eq!(f.kind, FootageKind::Still); - assert_eq!((f.width, f.height, f.codec.as_str()), (250, 100, codec), "{pf}"); + assert_eq!((f.width, f.height), (250, 100), "{pf}"); + // Some ffmpeg builds state the transfer (", linear"), which the codec name then carries. + assert!(f.codec == codec || f.codec.starts_with(&format!("{codec}, ")), "{pf}: {}", f.codec); assert_eq!(f.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, "{pf}"); let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect(pf); let want = ffmpeg_rgba(&p, !codec.contains("8-bit")); @@ -967,3 +1027,54 @@ fn cineon_still_shows_code_values() { assert!(effectcraft_media::probe_bytes("cut.cin", cut).is_err(), "{n}"); } } + +/// #329: a frame read that is called off (a scrub has moved on) gives up before the decoder +/// seeks and decodes forward from the keyframe, so it no longer holds the decoder the newest +/// frame waits for; the frame decodes as usual when it is wanted again. +#[test] +fn a_called_off_read_does_not_decode() { + use std::sync::atomic::AtomicBool; + let Some(p) = h264_mp4() else { return }; + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let t = t_of(f.frame_rate, 45); + let off = Arc::new(AtomicBool::new(true)); + let mut got = None; + pool.cancellable(&off, &mut || got = Some(pool.frame(ItemId(1), &f, t))); + assert!(got.expect("ran").is_none(), "a called-off read decoded its frame"); + assert_eq!(pool.stats().frames, 0, "nothing cached for it"); + let on = Arc::new(AtomicBool::new(false)); + let mut got = None; + pool.cancellable(&on, &mut || got = pool.frame(ItemId(1), &f, t)); + let img = got.expect("decoded once wanted again"); + assert_eq!((img.width, img.height), (1920, 1080)); +} + +/// H.264 High 4:2:2 10-bit: the software decoder lacks it; the pool says why, once, and the +/// footage reports it (not a black frame without a word). +#[test] +fn an_unsupported_stream_reports_why_it_shows_no_picture() { + let Some(p) = ffmpeg_fixture( + "h264_high422_10bit.mp4", + &["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"], + ) else { + return; + }; + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + assert_eq!(pool.problem(&f), None, "nothing is wrong before a frame was asked for"); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // a decoder that handles 4:2:2 (FilmCraft's, or a hardware one): no notice + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("4:2:2"), "names the stream: {why}"); + assert!(why.contains("isn't supported"), "says it is unsupported: {why}"); + assert!(!why.contains("decode error"), "no error wrappers: {why}"); + // it stays for the other frames, and is dropped when the file is forgotten + assert!(pool.frame(ItemId(1), &f, Tick::from_seconds_f64(0.5)).is_none()); + assert_eq!(pool.problem(&f).as_deref(), Some(why.as_str())); + pool.forget(&f.path); + assert_eq!(pool.problem(&f), None); +} diff --git a/crates/media/tests/open_in_place.rs b/crates/media/tests/open_in_place.rs new file mode 100644 index 00000000..78b0abcb --- /dev/null +++ b/crates/media/tests/open_in_place.rs @@ -0,0 +1,101 @@ +//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and +//! some audio must not grow peak memory by anything near the file's size. +//! +//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so +//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place, +//! the box is skipped by its header. The only test in this binary, so peak RSS is its own. +//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use effectcraft_media::{MediaPool, probe}; +use effectcraft_time::Tick; + +const PADDING: u64 = 256 << 20; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") +} + +/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test +/// processes never see a partial file). `None` without ffmpeg. +fn small_mp4() -> Option { + let out = fixtures().join("in_place_small.mp4"); + if out.exists() { + return Some(out); + } + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + return None; + } + std::fs::create_dir_all(fixtures()).unwrap(); + let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id())); + let st = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"]) + .args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .arg(&tmp) + .status() + .unwrap(); + assert!(st.success(), "ffmpeg failed"); + std::fs::rename(&tmp, &out).unwrap(); + Some(out) +} + +/// `/proc/self/status` field `key` in KiB. +fn status_kib(key: &str) -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let line = s.lines().find(|l| l.starts_with(key))?; + line.split_whitespace().nth(1)?.parse().ok() +} + +/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that. +fn reset_peak() -> Option { + let _ = std::fs::write("/proc/self/clear_refs", "5"); + status_kib("VmHWM:").map(|k| k * 1024) +} + +#[test] +fn a_long_movie_is_read_in_place_not_whole() { + if !cfg!(target_os = "linux") { + eprintln!("peak RSS is read from /proc: skipping"); + return; + } + let Some(small) = small_mp4() else { + eprintln!("ffmpeg not found: skipping"); + return; + }; + // The same movie with 256 MiB of padding after its boxes. + let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id())); + std::fs::copy(&small, &large).unwrap(); + let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap(); + f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap(); + f.write_all(b"free").unwrap(); + f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap(); + drop(f); + + // Warm up on the small file (code paths, allocator, decoder tables). + let small_footage = probe(&small).unwrap(); + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap(); + let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000); + + let before = reset_peak().unwrap(); + let footage = probe(&large).unwrap(); + let frame = pool.frame_at(&footage, Tick::ZERO).unwrap(); + let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000); + let after = status_kib("VmHWM:").unwrap() * 1024; + drop(pool); + let _ = std::fs::remove_file(&large); + + let growth = after.saturating_sub(before); + eprintln!("peak RSS growth: {} KiB", growth >> 10); + assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20); + // Same movie, same results. + assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage }); + assert_eq!(frame, small_frame); + assert_eq!(audio, small_audio); + assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes"); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c1f110e0..fee3b8ce 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod essential; +pub mod limits; pub mod props; pub mod render_queue; pub mod render_templates; @@ -48,6 +49,42 @@ pub fn saved_by(text: &str) -> Option { serde_json::from_str::(text).ok()?.saved_by } +/// The compositions that were open when a project file was saved (`activeComp` and `openComps` +/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project +/// content: opening a comp doesn't make the project unsaved, and it isn't part of +/// [`Project`]. Older files have none; older versions ignore it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedView { + /// The comp shown in the viewer and the Timeline. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_comp: Option, + /// The open comps (Timeline tabs), in tab order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub open_comps: Vec, +} + +/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that +/// aren't ids are ignored (whether an id still names a comp is up to the caller). +pub fn saved_view(text: &str) -> SavedView { + #[derive(Deserialize)] + struct Meta { + #[serde(rename = "activeComp", default)] + active_comp: Option, + #[serde(rename = "openComps", default)] + open_comps: Option, + } + let Ok(m) = serde_json::from_str::(text) else { return SavedView::default() }; + SavedView { + active_comp: m.active_comp.as_ref().and_then(serde_json::Value::as_u64).map(ItemId), + open_comps: m + .open_comps + .as_ref() + .and_then(serde_json::Value::as_array) + .map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()), + } +} + /// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`. pub fn is_newer_version(a: &str, b: &str) -> bool { let parts = |v: &str| -> [u64; 3] { @@ -301,6 +338,71 @@ pub enum Renderer { Advanced3D, } +/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸ +/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Resolution { + #[default] + Auto, + Full, + Half, + Third, + Quarter, + /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). + Custom(u8), +} + +impl Resolution { + pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; + pub fn label(self) -> &'static str { + match self { + Resolution::Auto => "Auto", + Resolution::Full => "Full", + Resolution::Half => "Half", + Resolution::Third => "Third", + Resolution::Quarter => "Quarter", + Resolution::Custom(_) => "Custom", + } + } + pub fn is_auto(&self) -> bool { + *self == Resolution::Auto + } + /// The down-sample factor (render every n-th pixel); `None` for Auto. + pub fn factor(self) -> Option { + match self { + Resolution::Auto => None, + Resolution::Full => Some(1), + Resolution::Half => Some(2), + Resolution::Third => Some(3), + Resolution::Quarter => Some(4), + Resolution::Custom(n) => Some(n), + } + } + /// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40). + pub fn from_factor(n: u64) -> Resolution { + match n.clamp(1, 40) { + 1 => Resolution::Full, + 2 => Resolution::Half, + 3 => Resolution::Third, + 4 => Resolution::Quarter, + n => Resolution::Custom(n as u8), + } + } + /// Render scale for a viewer magnification (and display pixel density). + pub fn scale(self, zoom: f32, ppp: f32) -> f64 { + match self.factor() { + Some(n) => 1.0 / f64::from(n.clamp(1, 40)), + // As in After Effects, Auto renders the pixels the magnification needs: the coarsest + // whole factor that still gives every screen pixel a rendered one (Full above 50 %, + // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). + None => { + let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); + if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } + } + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Comp { pub width: u32, @@ -354,6 +456,11 @@ pub struct Comp { /// Essential Graphics: the controls this comp exposes to instances and templates. #[serde(default, skip_serializing_if = "Option::is_none")] pub essential: Option, + /// The Composition panel's Resolution, saved with the project as in After Effects. It changes + /// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of + /// the undo history. + #[serde(default, skip_serializing_if = "Resolution::is_auto")] + pub resolution: Resolution, } /// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position. @@ -399,6 +506,7 @@ impl Comp { guides: vec![], global_light: styles::GlobalLight::default(), essential: None, + resolution: Resolution::Auto, } } /// Whether the composition has a frame at its time `t`: nested, it shows nothing before it @@ -698,8 +806,8 @@ impl Layer { } impl Comp { - /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and - /// Hide Shy Layers. + /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide + /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { // Every field (no `..`: a new field must be considered here); the layers last. let Comp { @@ -728,6 +836,7 @@ impl Comp { global_light, guides, essential, + resolution: _, } = self; *width == o.width && *height == o.height @@ -756,14 +865,15 @@ impl Comp { && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) } - /// The comp with those switches at their defaults (borrowed when they already are), for - /// keys that hash the whole comp. + /// The comp with those switches and the Resolution at their defaults (borrowed when they + /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; + c.resolution = Resolution::Auto; for l in &mut c.layers { l.switches = l.switches.pixels(); } @@ -1246,7 +1356,9 @@ impl Project { _ => None, } } - pub fn add_item(&mut self, name: &str, label: Label, parent: Option, kind: ItemKind) -> ItemId { + /// Adds an item; its size, frame rate and the like are brought within [`limits`] first. + pub fn add_item(&mut self, name: &str, label: Label, parent: Option, mut kind: ItemKind) -> ItemId { + limits::repair_kind(name, &mut kind, &mut vec![]); let id = ItemId(self.alloc()); self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None }); id @@ -1315,14 +1427,20 @@ impl Project { /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or /// infinity: such a value would be written as `null`). pub fn to_file_json(&self) -> Result { + self.to_file_json_with_view(&SavedView::default()) + } + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). + pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { #[derive(Serialize)] struct File<'a> { #[serde(rename = "savedBy")] saved_by: &'a str, #[serde(flatten)] + view: &'a SavedView, + #[serde(flatten)] project: &'a Project, } - let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self }) + let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self }) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; @@ -1336,12 +1454,22 @@ impl Project { serde_json::to_string(self).unwrap_or_default() } pub fn from_json(s: &str) -> Result { + Project::from_json_repaired(s).map(|(p, _)| p) + } + /// [`Project::from_json`], also returning one line per value that was out of [`limits`] and + /// was corrected (a composition of a billion pixels, a zero frame rate…), so a corrupt or + /// hostile file still opens, within bounds, and the user can be told what changed. + pub fn from_json_repaired(s: &str) -> Result<(Project, Vec), ProjectError> { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; if p.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema))); } p.fix_next_id(); - Ok(p) + let mut notes = vec![]; + for item in p.items.values_mut() { + limits::repair_item(item, &mut notes); + } + Ok((p, notes)) } } diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs new file mode 100644 index 00000000..998f4022 --- /dev/null +++ b/crates/project/src/limits.rs @@ -0,0 +1,227 @@ +//! The bounds every project holds to (#788). After Effects limits compositions, solids and +//! imported images to 30,000 × 30,000 pixels; the Composition Settings commands already used that +//! limit, a frame-rate range and a motion-blur sample range. A project file is input like any +//! other: [`Project::from_json_repaired`](crate::Project::from_json_repaired) brings what it reads +//! into these bounds (and says what it changed) and [`Project::add_item`](crate::Project::add_item) +//! does the same for every new item, so a corrupt or hostile file can't make the renderer allocate +//! a frame of billions of pixels or step through zero-length frames. + +use std::fmt::Display; +use std::sync::Arc; + +use effectcraft_time::FrameRate; + +use crate::{Comp, Footage, Item, ItemKind, Solid}; + +/// The largest width or height of a composition, solid or footage frame, in pixels. +pub const MAX_SIZE: u32 = 30_000; +/// The slowest frame rate, in frames per second. +pub const MIN_FPS: f64 = 0.001; +/// The fastest frame rate, in frames per second. +pub const MAX_FPS: f64 = 1000.0; +/// The narrowest pixel aspect ratio. +pub const MIN_PIXEL_ASPECT: f64 = 0.1; +/// The widest pixel aspect ratio. +pub const MAX_PIXEL_ASPECT: f64 = 10.0; +/// Composition Settings ▸ Advanced ▸ Samples Per Frame. +pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); +/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. +pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); + +/// A width or height from 1 to [`MAX_SIZE`]. +pub fn size(v: u64) -> u32 { + // In range after the clamp, so the cast can't truncate. + v.clamp(1, u64::from(MAX_SIZE)) as u32 +} + +/// [`size`] of a measured length (rounded; 1 for NaN). +pub fn size_f64(v: f64) -> u32 { + if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIZE)) as u32 } else { 1 } +} + +/// A pixel aspect ratio from [`MIN_PIXEL_ASPECT`] to [`MAX_PIXEL_ASPECT`] (square for a value +/// that isn't a positive number). +pub fn pixel_aspect(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.clamp(MIN_PIXEL_ASPECT, MAX_PIXEL_ASPECT) } else { 1.0 } +} + +/// A frame rate from [`MIN_FPS`] to [`MAX_FPS`]; `fallback` for a zero or negative rate. +pub fn frame_rate(r: FrameRate, fallback: FrameRate) -> FrameRate { + if r.num <= 0 || r.den <= 0 { + return fallback; + } + let fps = r.as_f64(); + if (MIN_FPS..=MAX_FPS).contains(&fps) { r } else { FrameRate::from_f64(fps.clamp(MIN_FPS, MAX_FPS)) } +} + +/// Records one corrected value. +fn note(notes: &mut Vec, item: &str, what: &str, from: impl Display, to: impl Display) { + notes.push(format!("{item}: {what} {from} → {to}")); +} + +fn fps(r: FrameRate) -> String { + format!("{}/{} fps", r.num, r.den) +} + +/// Brings a read item (and its proxy) within the limits; one line per corrected value is added +/// to `notes`. +pub(crate) fn repair_item(item: &mut Item, notes: &mut Vec) { + repair_kind(&item.name, &mut item.kind, notes); + if let Some(proxy) = &mut item.proxy { + repair_footage(&format!("{} (proxy)", item.name), &mut proxy.footage, notes); + } +} + +/// Brings `kind` within the limits; one line per corrected value is added to `notes`. +pub(crate) fn repair_kind(name: &str, kind: &mut ItemKind, notes: &mut Vec) { + match kind { + ItemKind::Comp(c) => { + if let Some(fixed) = repair_comp(name, c, notes) { + *c = Arc::new(fixed); + } + } + ItemKind::Solid(s) => repair_solid(name, s, notes), + ItemKind::Footage(f) => repair_footage(name, f, notes), + ItemKind::Folder => {} + } +} + +/// The comp with its settings within the limits, or `None` when they already are (so a +/// composition shared with undo snapshots is only copied when something changes). +fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { + let width = c.width.clamp(1, MAX_SIZE); + let height = c.height.clamp(1, MAX_SIZE); + let pa = pixel_aspect(c.pixel_aspect); + let rate = frame_rate(c.frame_rate, FrameRate::FPS_29_97); + // At least one frame long. + let duration = c.duration.max(rate.frame_duration()); + let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); + let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let unchanged = (width, height, rate, duration, samples, adaptive) + == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) + && pa.to_bits() == c.pixel_aspect.to_bits(); + if unchanged { + return None; + } + let mut n = c.clone(); + if (width, height) != (c.width, c.height) { + note(notes, name, "size", format_args!("{} × {} px", c.width, c.height), format_args!("{width} × {height} px")); + } + if pa.to_bits() != c.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", c.pixel_aspect, pa); + } + if rate != c.frame_rate { + note(notes, name, "frame rate", fps(c.frame_rate), fps(rate)); + } + if duration != c.duration { + note(notes, name, "duration", format_args!("{:.3} s", c.duration.seconds()), format_args!("{:.3} s", duration.seconds())); + n.work_area = (effectcraft_time::Tick::ZERO, duration); + } + if samples != c.motion_blur_samples { + note(notes, name, "motion blur samples per frame", c.motion_blur_samples, samples); + } + if adaptive != c.motion_blur_adaptive_limit { + note(notes, name, "adaptive sample limit", c.motion_blur_adaptive_limit, adaptive); + } + (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); + (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + Some(n) +} + +fn repair_solid(name: &str, s: &mut Solid, notes: &mut Vec) { + let (w, h) = (s.width.clamp(1, MAX_SIZE), s.height.clamp(1, MAX_SIZE)); + if (w, h) != (s.width, s.height) { + note(notes, name, "size", format_args!("{} × {} px", s.width, s.height), format_args!("{w} × {h} px")); + (s.width, s.height) = (w, h); + } + let pa = pixel_aspect(s.pixel_aspect); + if pa.to_bits() != s.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", s.pixel_aspect, pa); + s.pixel_aspect = pa; + } +} + +fn repair_footage(name: &str, f: &mut Footage, notes: &mut Vec) { + // Audio-only footage has no frame (0 × 0). + let (w, h) = (f.width.min(MAX_SIZE), f.height.min(MAX_SIZE)); + if (w, h) != (f.width, f.height) { + note(notes, name, "size", format_args!("{} × {} px", f.width, f.height), format_args!("{w} × {h} px")); + (f.width, f.height) = (w, h); + } + let pa = pixel_aspect(f.pixel_aspect); + if pa.to_bits() != f.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", f.pixel_aspect, pa); + f.pixel_aspect = pa; + } + let rate = frame_rate(f.frame_rate, FrameRate::FPS_30); + if rate != f.frame_rate { + note(notes, name, "frame rate", fps(f.frame_rate), fps(rate)); + f.frame_rate = rate; + } + if let Some(native) = f.native_rate { + let rate = frame_rate(native, FrameRate::FPS_30); + if rate != native { + note(notes, name, "native frame rate", fps(native), fps(rate)); + f.native_rate = Some(rate); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_time::Tick; + + #[test] + fn helpers_clamp_and_refuse_nonsense() { + assert_eq!(size(0), 1); + assert_eq!(size(u64::from(u32::MAX) + 5), MAX_SIZE); + assert_eq!(size_f64(f64::NAN), 1); + assert_eq!(size_f64(1e12), MAX_SIZE); + assert_eq!(pixel_aspect(0.0), 1.0); + assert_eq!(pixel_aspect(-2.0), 1.0); + assert_eq!(pixel_aspect(1e9), MAX_PIXEL_ASPECT); + assert_eq!(frame_rate(FrameRate { num: 0, den: 1 }, FrameRate::FPS_25), FrameRate::FPS_25); + assert_eq!(frame_rate(FrameRate { num: 24, den: -1 }, FrameRate::FPS_25), FrameRate::FPS_25); + assert_eq!(frame_rate(FrameRate::new(1_000_000_000_000_000_000, 1), FrameRate::FPS_25).as_f64(), MAX_FPS); + assert_eq!(frame_rate(FrameRate::FPS_29_97, FrameRate::FPS_25), FrameRate::FPS_29_97); + } + + #[test] + fn add_item_and_reading_a_file_hold_footage_to_the_limits() { + let mut p = crate::Project::default(); + let wild = Footage { width: u32::MAX, height: 0, pixel_aspect: 0.0, frame_rate: FrameRate { num: 1, den: 0 }, ..Default::default() }; + let id = p.add_item("Clip", effectcraft_color::Label::None, None, ItemKind::Footage(wild)); + let ItemKind::Footage(f) = &p.items[&id].kind else { panic!("footage") }; + assert_eq!((f.width, f.height, f.pixel_aspect, f.frame_rate), (MAX_SIZE, 0, 1.0, FrameRate::FPS_30)); + // The same values in a file are corrected, and each correction is reported. + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + let kind = &mut v["items"][id.0.to_string()]["kind"]; + kind["width"] = serde_json::json!(4_000_000_000u64); + kind["native_rate"] = serde_json::json!({"num": -24, "den": 1}); + let mut proxy = kind.clone(); + proxy["frame_rate"] = serde_json::json!({"num": 0, "den": 0}); + v["items"][id.0.to_string()]["proxy"] = serde_json::json!({"footage": proxy}); + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let ItemKind::Footage(b) = &back.items[&id].kind else { panic!("footage") }; + assert_eq!((b.width, b.native_rate), (MAX_SIZE, Some(FrameRate::FPS_30))); + assert_eq!( + notes, + [ + "Clip: size 4000000000 × 0 px → 30000 × 0 px", + "Clip: native frame rate -24/1 fps → 30/1 fps", + "Clip (proxy): size 4000000000 × 0 px → 30000 × 0 px", + "Clip (proxy): frame rate 0/0 fps → 30/1 fps", + "Clip (proxy): native frame rate -24/1 fps → 30/1 fps", + ] + ); + } + + #[test] + fn a_comp_in_range_is_not_copied() { + let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); + let mut notes = vec![]; + assert!(repair_comp("Comp", &c, &mut notes).is_none()); + assert!(notes.is_empty()); + } +} diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index edf9a256..494c2a9e 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -65,6 +65,54 @@ fn animate_and_roundtrip_json() { let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0)); } +#[test] +fn comp_resolution_is_saved_and_old_projects_load_with_auto() { + use crate::Resolution; + let (mut p, cid) = project_with_layer(); + // Auto (the default) isn't written, so older versions read the file as before. + assert!(!p.to_file_json().unwrap().contains("\"resolution\"")); + p.comp_mut(cid).unwrap().resolution = Resolution::Custom(5); + let q = Project::from_json(&p.to_file_json().unwrap()).unwrap(); + assert_eq!(q.comp(cid).unwrap().resolution, Resolution::Custom(5)); + p.comp_mut(cid).unwrap().resolution = Resolution::Half; + let text = p.to_file_json().unwrap(); + assert_eq!(Project::from_json(&text).unwrap().comp(cid).unwrap().resolution, Resolution::Half); + // A project saved before comps had a resolution opens with Auto. + fn strip(v: &mut serde_json::Value) { + match v { + serde_json::Value::Object(m) => { + m.remove("resolution"); + m.values_mut().for_each(strip); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); + strip(&mut old); + assert_eq!(Project::from_json(&old.to_string()).unwrap().comp(cid).unwrap().resolution, Resolution::Auto); + // It's not part of the comp's pixels. + let (a, b) = (p.comp(cid).unwrap(), q.comp(cid).unwrap()); + assert!(a != b && a.same_pixels(b)); + assert_eq!(format!("{:?}", a.pixel_form()), format!("{:?}", b.pixel_form())); +} + +#[test] +fn resolution_factors_and_scales() { + use crate::Resolution; + assert_eq!(Resolution::from_factor(1), Resolution::Full); + assert_eq!(Resolution::from_factor(4), Resolution::Quarter); + assert_eq!(Resolution::from_factor(0), Resolution::Full); + assert_eq!(Resolution::from_factor(7), Resolution::Custom(7)); + assert_eq!(Resolution::from_factor(1000), Resolution::Custom(40)); + assert_eq!(Resolution::Custom(5).factor(), Some(5)); + assert_eq!(Resolution::Auto.factor(), None); + // A hostile factor from a file renders within the Custom range. + assert_eq!(Resolution::Custom(0).scale(1.0, 1.0), 1.0); + assert_eq!(Resolution::Custom(255).scale(1.0, 1.0), 1.0 / 40.0); + assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0); +} + #[test] fn unique_names_and_cycles() { let (p, cid) = project_with_layer(); @@ -121,3 +169,16 @@ fn project_files_name_their_version_and_always_read_back() { assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); } + +#[test] +fn project_files_remember_the_open_comps() { + let (p, cid) = project_with_layer(); + let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] }; + let text = p.to_file_json_with_view(&view).unwrap(); + assert_eq!(crate::saved_view(&text), view); + assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); + // Files from before #748 have no view; a garbled one is ignored, not an error. + assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default()); + let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1); + assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] }); +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index d221ac59..4f2ffc52 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -74,7 +74,8 @@ pub fn audible(project: &Project, l: &Layer) -> bool { }) } -fn db_to_gain(db: f64) -> f32 { +/// Linear gain of an Audio Levels value in dB; -96 dB and below is silence. +pub fn db_to_gain(db: f64) -> f32 { if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b15fc019..0a47937e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ pub mod text; pub mod three_d; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; pub use auto::{AutoKey, AutoPick}; pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore}; @@ -110,6 +111,93 @@ pub trait FootageSource: Send + Sync { fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option> { None } + /// Run `f`, which reads frames from this source, as work that can be called off: once + /// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding + /// forward) stop early and their frames come back `None`. Sources that can't stop a decode + /// just run `f`. See [`CancellableFootage`]. + fn cancellable(&self, _cancel: &Arc, f: &mut dyn FnMut()) { + f() + } + /// Reads through this source have been called off: frames may have come back `None` that + /// exist, so what was made from them must not be kept. + fn called_off(&self) -> bool { + false + } + /// Why `footage` shows no picture although its file is there: a read of it failed because + /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged + /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it + /// decodes, and for sources that don't track failures. Stays set until the file is forgotten. + fn problem(&self, _footage: &Footage) -> Option { + None + } +} + +/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` +/// (a viewer frame nobody waits for any more, #329). Every read goes through +/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread +/// (a parallel loop) answer to this render's flag, and reads for other renders never do. +pub struct CancellableFootage<'a> { + pub inner: &'a dyn FootageSource, + pub cancel: Arc, +} + +impl CancellableFootage<'_> { + fn read(&self, f: impl FnOnce() -> Option) -> Option { + if self.cancel.load(Ordering::Relaxed) { + return None; + } + let (mut f, mut out) = (Some(f), None); + self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f())); + out + } +} + +impl FootageSource for CancellableFootage<'_> { + fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.read(|| self.inner.frame(item, footage, t)) + } + fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.read(|| self.inner.frame_at_size(item, footage, t, width, height)) + } + fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + self.inner.audio(item, footage, t, frames, rate) + } + fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.inner.aux(item, footage, t) + } + fn model(&self, item: ItemId, footage: &Footage) -> Option> { + self.inner.model(item, footage) + } + fn set_cache_budget(&self, bytes: usize) { + self.inner.set_cache_budget(bytes) + } + fn purge(&self) { + self.inner.purge() + } + fn forget(&self, path: &str) { + self.inner.forget(path) + } + fn set_conform_folder(&self, folder: Option) { + self.inner.set_conform_folder(folder) + } + fn cache_budget(&self) -> Option { + self.inner.cache_budget() + } + fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> { + self.inner.vector_frame(item, footage, scale) + } + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + self.inner.cancellable(cancel, f) + } + fn called_off(&self) -> bool { + self.cancel.load(Ordering::Relaxed) || self.inner.called_off() + } + fn problem(&self, footage: &Footage) -> Option { + self.inner.problem(footage) + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.read(|| self.inner.frame_with(item, footage, t, size, float)) + } } /// Footage that can be rasterised at any scale (SVG). @@ -402,6 +490,16 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -604,6 +702,12 @@ impl<'a> Renderer<'a> { } } + /// The layer cache to keep this render's buffers in: none once its footage reads were + /// called off, as they may have come back empty ([`FootageSource::called_off`], #329). + fn cache_to_keep(&self) -> Option<&'a LayerCache> { + self.cache.filter(|_| !self.footage.called_off()) + } + /// A layer's Quality switch, limited by the precomp layers above it when /// [`RenderOpts::nested_switches`] is on. pub fn quality(&self, layer: &Layer) -> Quality { @@ -757,10 +861,6 @@ impl<'a> Renderer<'a> { Region::Full } - /// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are - /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — - /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so - /// vector content is drawn at its final size and stays sharp when scaled up). /// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as /// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats /// re-running its effects every frame. @@ -774,11 +874,14 @@ impl<'a> Renderer<'a> { now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur) } + /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, + /// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector + /// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded + /// up, so vector content is drawn at its final size and stays sharp when scaled up). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; let continuous = match layer.source { - LayerSource::Text => true, - LayerSource::Shape => layer.switches.collapse, + LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } @@ -1029,6 +1132,15 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; + // #659: an authored layer's buffer spans only its painted content while the stack works + // on the layer's whole region (`layer_size`): give the buffer the room a sized source + // already has, so tiling or displacement can place pixels outside the content instead + // of losing them at its edge. Only when something will actually run, and not for an + // adjustment layer, whose buffer is the comp below and already spans it. + if !adjustment + && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { + target.cover(layer_size); + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { @@ -1292,7 +1404,7 @@ impl<'a> Renderer<'a> { let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); } return Some(Arc::new(img)); @@ -1340,7 +1452,7 @@ impl<'a> Renderer<'a> { let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); - if let (Some(cache), Some(k)) = (self.cache, key) { + if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) { cache.insert(k, nb.clone()); } nb @@ -1358,7 +1470,7 @@ impl<'a> Renderer<'a> { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, flat.clone()); } Some((flat, key)) @@ -1389,7 +1501,7 @@ impl<'a> Renderer<'a> { } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, buf.clone()); } Some((buf, key)) @@ -1414,7 +1526,7 @@ impl<'a> Renderer<'a> { buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); } let buf = Arc::new(buf); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, buf.clone()); } Some(buf) @@ -1446,7 +1558,7 @@ impl<'a> Renderer<'a> { } let body = Arc::new(st.body); let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, body.clone()); for (i, (b, _)) in passes.iter().enumerate() { c.insert(cache::derive(k, i as u64 + 1), b.clone()); @@ -2070,7 +2182,7 @@ impl<'a> Renderer<'a> { let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, foot.clone()); } Some(foot) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7e065391..45c00a64 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -135,6 +135,32 @@ fn track_matte_alpha() { assert!(img.get(150, 50)[3] < 0.01); } +/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain +/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864). +#[test] +fn track_matte_kinds_keep_and_invert() { + for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] { + let (mut p, cid, comp) = setup(); + let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50); + matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + matte.switches.video = false; + let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + fill.track_matte = Some(TrackMatte { layer: matte.id, kind }); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(matte); + c.layers.push(fill); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]); + if inside { + assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } else { + assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } + } +} + #[test] fn shape_layer_draws_fill_and_stroke() { let (mut p, cid, comp) = setup(); @@ -268,6 +294,23 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC r.comp_frame(cid, t) } +/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer +/// made from a frame that came back empty (an effect's layer parameter) would be reused by the +/// next render of that time. +#[test] +fn a_called_off_render_keeps_nothing_in_the_layer_cache() { + let (p, cid) = cache_scene(); + let t = Tick::from_seconds_f64(0.5); + let cache = crate::LayerCache::default(); + let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) }; + let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, t); + assert_eq!(cache.stats().entries, 0, "a called-off render kept layers"); + render_cached(&p, cid, t, Some(&cache)); + assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers"); +} + /// [`add_effect`] for a 200×100 layer. fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) { add_effect(p, l, id, [200.0, 100.0], vals); @@ -597,6 +640,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { } } +/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs +/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect +/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the +/// probe is the x-span of the painted pixels, not their count. +#[test] +fn effects_on_text_reach_past_the_text_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let plain = render_frame(&p, cid, Tick::ZERO, 1.0); + let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); + add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); + p.comp_mut(cid).unwrap().layers = vec![l]; + let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); + let (mut lo, mut hi) = (u32::MAX, 0u32); + for y in 0..100 { + for x in 0..200 { + if tiled.get(x as i64, y as i64)[3] > 0.5 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + assert!( + painted > 50 && hi.saturating_sub(lo) > 100, + "CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" + ); +} + +/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past +/// the painted rectangle used to fall off its edge, so the liquid trail never appeared. +#[test] +fn cc_smear_trails_text_past_its_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0); + let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + add_effect_200( + &mut p, + &mut l, + "ec.distort.ccsmear", + &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], + ); + p.comp_mut(cid).unwrap().layers = vec![l]; + let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); +} + /// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the /// effect's own masks are in effect space too: Set Matte with a centred shape layer as the /// matte keeps the centre of a full-comp solid (#227). diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index f13dedd1..79796003 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize { (x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count() } -#[test] -fn continuously_rasterized_shape_stays_sharp_when_scaled_up() { - let run = |cr: bool| { - let mut p = project(); - let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); - let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); - let mut next = p.next_id; - let mut ids = Ids(&mut next); - let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); - let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); - let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); - p.next_id = next; - l.props.sub_mut("contents").unwrap().children.push(g.into()); - set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); +/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent +/// null's, with the Continuously Rasterize switch `cr`. +fn scaled_square(cr: bool, via_parent: bool) -> Image { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + l.switches.collapse = cr; + let mut c = comp.clone(); + if via_parent { + let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); + set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); + set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); + l.parent = Some(null.id); + c.layers = vec![l, null]; + } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - l.switches.collapse = cr; - let mut c = comp; c.layers = vec![l]; - let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); - render_frame(&p, cid, Tick::ZERO, 1.0) - }; + } + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) +} + +/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up +/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased +/// edge, whether or not the Continuously Rasterize switch is on. +#[test] +fn shape_scaled_up_stays_sharp_without_the_switch() { // The 10 px square scaled 800% spans 60.3..140.3. - let soft = run(false); - let sharp = run(true); - let ws = edge_width(&soft, 100, 50, 75); - let wc = edge_width(&sharp, 100, 50, 75); - assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}"); - assert!(wc <= 1, "continuously rasterized edge: {wc}"); - assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01); + for cr in [false, true] { + for via_parent in [false, true] { + let img = scaled_square(cr, via_parent); + let w = edge_width(&img, 100, 50, 75); + assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide"); + assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01); + } + } } #[test] @@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() { assert_eq!(k, 1.0, "text at {pos:?}"); } } + +/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as +/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like +/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751). +#[test] +fn effects_on_a_scaled_shape_follow_its_scale() { + let run = |size: f64, scale: f64, blur: f64| { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]); + p.next_id = next; + fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.props.sub_mut("effects").unwrap().children.push(fx.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + let scaled = run(10.0, 800.0, 2.0); + let native = run(80.0, 100.0, 16.0); + let d = max_diff(&scaled, &native); + assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge"); + assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 11318faf..55dfd630 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -167,9 +167,10 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false); let div = if units_index { n.max(1) as f64 } else { 100.0 }; Range { - start: ctx.f(layer, sel, "start", 0.0) / div, - end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div, - offset: ctx.f(layer, sel, "offset", 0.0) / div, + // Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%. + start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div, + end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div, + offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div, amount: af("amount", 100.0) / 100.0, shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)), smoothness: af("smoothness", 100.0) / 100.0, diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 835f9d33..547c02d4 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -99,6 +99,30 @@ fn range_based_on_words_and_lines() { assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); } +#[test] +fn range_start_end_and_offset_are_capped() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0)); + let a = animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0)); + // End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing. + // Uncapped it would be -150%..150% and select everything. + set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0)); + set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0)); + p.comp_mut(cid).unwrap().layers.push(l); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]); + // Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing. + // Uncapped it would be -100%..150% and select everything. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0)); + set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0)); + set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0)); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0)); + assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]); +} + #[test] fn selector_modes_and_randomize() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index b0a5f472..8c0f9d23 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -457,9 +457,29 @@ pub struct Views3D { /// Edited view cameras (views not present use their defaults). #[serde(default)] pub cams: BTreeMap, + /// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2); + /// panes not present show their default ([`Views3D::DEFAULT_PANES`]). + #[serde(default)] + pub panes: Vec, } impl Views3D { + /// Views the extra panes of a multi-view layout start with: Top, Front and Right. + pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right]; + /// The view of extra pane `i` (0 = the first beside the main view). + pub fn pane(&self, i: usize) -> View3D { + self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default() + } + /// Choose the view of extra pane `i` (ignored past the third). + pub fn set_pane(&mut self, i: usize, v: View3D) { + if i >= Self::DEFAULT_PANES.len() { + return; + } + while self.panes.len() <= i { + self.panes.push(Self::DEFAULT_PANES[self.panes.len()]); + } + self.panes[i] = v; + } pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam { self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h)) } diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index 093d0c1c..10c635eb 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -26,3 +26,4 @@ boa_engine = { workspace = true, features = ["js"] } [dev-dependencies] effectcraft-expr = { workspace = true } +effectcraft-psd = { workspace = true } diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index a48a36a0..d8a8f3aa 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -61,8 +61,24 @@ const TEXT: &[(&str, &str)] = &[ ("animators", "ADBE Text Animators"), ]; +// Match names of the six properties below Text ▸ Path Options, as named by +// After Effects' scripting API (rather than EffectCraft's internal ids). +const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[ + ("path", "ADBE Text Path"), + ("reversePath", "ADBE Text Reverse Path"), + ("perpendicular", "ADBE Text Perpendicular To Path"), + ("forceAlignment", "ADBE Text Force Align Path"), + ("firstMargin", "ADBE Text First Margin"), + ("lastMargin", "ADBE Text Last Margin"), +]; + const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +const TEXT_SELECTORS: &[(&str, &str)] = + &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; + +const TEXT_RANGE_SELECTOR: &[(&str, &str)] = &[("start", "ADBE Text Percent Start"), ("end", "ADBE Text Percent End"), ("offset", "ADBE Text Percent Offset")]; + const CAMERA: &[(&str, &str)] = &[ ("zoom", "ADBE Camera Zoom"), ("dof", "ADBE Camera Depth of Field"), @@ -411,7 +427,10 @@ pub fn node(chain: &[&Node]) -> String { } } "text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "pathOptions" => find(TEXT_PATH_OPTIONS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "animators" => "ADBE Text Animator".into(), + "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), "lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own), @@ -458,4 +477,15 @@ mod tests { assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill")); assert_eq!(shape_kind("nope"), None); } + + #[test] + fn text_range_selector_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, selectors, range, start, end, offset) = + (n("text"), n("animators"), n("animator"), n("selectors"), n("rangeSelector"), n("start"), n("end"), n("offset")); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range]), "ADBE Text Selector"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &start]), "ADBE Text Percent Start"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &end]), "ADBE Text Percent End"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &offset]), "ADBE Text Percent Offset"); + } } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 18770041..9daac6ab 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -6,8 +6,16 @@ var __ecGlobal = this; +// The scripting host keeps wrappers stable for each project item and comp/layer id. +// Clear them when loading a different document, whose IDs may be reused. +var __itemCache = Object.create(null), __layerCache = Object.create(null); +function __clearIdentityCache() { + __itemCache = Object.create(null); + __layerCache = Object.create(null); +} function __call(id, p) { var r = __exec(id, JSON.stringify(p || {})); + if (id === "file.newProject" || id === "file.open" || id === "file.revert") __clearIdentityCache(); return r === undefined || r === "" ? null : JSON.parse(r); } function __get(kind, a) { @@ -27,6 +35,25 @@ function __str(v) { return String(v); } +// ExtendScript includes the ES3 String.substr(start, length) method, which Boa +// does not currently provide. Follow its legacy length (not end-index) semantics. +if (typeof String.prototype.substr !== "function") { + Object.defineProperty(String.prototype, "substr", { + value: function (start, length) { + if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined"); + var text = String(this), n = text.length; + var from = Number(start); + if (isNaN(from)) from = 0; + // ToIntegerOrInfinity, then clamp positive or negative offsets. + from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n); + var count = length === undefined ? n - from : Number(length); + if (isNaN(count) || count <= 0) return ""; + return text.slice(from, from + count); + }, + writable: true, configurable: true + }); +} + // ------------------------------------------------------------------ enums function __enum(name, keys, base) { @@ -67,6 +94,9 @@ var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGH var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012); var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212); var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812); +// Accept the longer names used by some ExtendScript hosts. +ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS; +ImportAsType.COMPOSITION = ImportAsType.COMP; var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); @@ -179,7 +209,7 @@ File.prototype = { get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); }, get parent() { return new Folder(this.path); }, get exists() { return JSON.parse(__file("exists", this.__path)) === true; }, - get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); }, + get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)).length : 0); }, get eof() { return this.__buf === null || this.__pos >= this.__buf.length; }, toString: function () { return this.__path; }, open: function (mode) { @@ -193,9 +223,9 @@ File.prototype = { this.__buf = ""; return true; } - this.__buf = JSON.parse(__file("read", this.__path)); + this.__buf = JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)); } else if (mode === "a") { - this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : ""; + this.__buf = this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)) : ""; this.__pos = this.__buf.length; } else { // Check the gate now, like After Effects refusing to open a file for writing. @@ -232,18 +262,14 @@ File.prototype = { tell: function () { return this.__pos; }, close: function () { if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) { - __file("write", this.__path, this.__buf); + __file(this.encoding.toUpperCase() === "BINARY" ? "writeBinary" : "write", this.__path, this.__buf); } this.__buf = null; this.__mode = null; return true; }, remove: function () { return JSON.parse(__file("remove", this.__path)); }, - copy: function (to) { - var data = JSON.parse(__file("read", this.__path)); - __file("write", __str(to), data); - return true; - }, + copy: function (to) { return JSON.parse(__file("copy", this.__path, __str(to))); }, rename: function () { throw __err("File.rename is not supported"); }, execute: function () { return JSON.parse(__file("execute", this.__path)); }, openDlg: function () { return null; }, @@ -903,6 +929,19 @@ Property.prototype.property = function () { return null; }; return __call(id, params); }; P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); }; + // EffectCraft extension: remove an inclusive range with one engine edit instead of + // cloning the project for every removed key. Indices are one-based as in removeKey. + P.removeKeys = function (first, last) { + var keys = this.__keys(); + first = __num(first, "first key"); + last = __num(last, "last key"); + if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first || last > keys.length) + throw __err("removeKeys: key range is out of bounds"); + var targets = []; + for (var i = first; i <= last; i++) + targets.push({ layer: this.__layer.__id, prop: this.__uid, index: i - 1 }); + return __call("keys.delete", { comp: this.__layer.__comp, __keys: targets }); + }; P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; }; P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; }; // Values that don't interpolate (checkboxes, popups, text) only hold. @@ -1009,7 +1048,7 @@ MarkerProperty.prototype = { t = __num(t, "time"); if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue"); var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 }); - p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; + p.cuePoint = (mv.cuePointName || mv.__params.length) ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; var l = this.__list(), at = -1; for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i; if (at >= 0) p.index = at; @@ -1025,14 +1064,18 @@ MarkerProperty.prototype = { function __layer(compId, id) { if (id === null || id === undefined) return null; - var i = __get("layer", { comp: compId, layer: id }); + var key = compId + ":" + id; + if (Object.prototype.hasOwnProperty.call(__layerCache, key)) return __layerCache[key]; + var i = __get("layer", { comp: compId, layer: id }), layer; switch (i.kind) { - case "text": return new TextLayer(compId, id); - case "shape": return new ShapeLayer(compId, id); - case "camera": return new CameraLayer(compId, id); - case "light": return new LightLayer(compId, id); - default: return new AVLayer(compId, id); + case "text": layer = new TextLayer(compId, id); break; + case "shape": layer = new ShapeLayer(compId, id); break; + case "camera": layer = new CameraLayer(compId, id); break; + case "light": layer = new LightLayer(compId, id); break; + default: layer = new AVLayer(compId, id); } + __layerCache[key] = layer; + return layer; } function Layer() {} @@ -1068,7 +1111,7 @@ Layer.prototype = { set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); }, get index() { return this.__info().index; }, get matchName() { return this.__info().matchName; }, - get containingComp() { return new CompItem(this.__comp); }, + get containingComp() { return __item(this.__comp); }, get propertyDepth() { return 0; }, get propertyType() { return PropertyType.INDEXED_GROUP; }, get parentProperty() { return null; }, @@ -1110,6 +1153,7 @@ Layer.prototype = { __call("edit.label", this.__p({ label: n })); }, get comment() { return this.__info().comment; }, + set comment(v) { __call("layer.setComment", this.__p({ comment: String(v) })); }, get selected() { return this.__info().selected; }, set selected(v) { var sel = this.containingComp.__info().selectedLayers; @@ -1177,6 +1221,11 @@ AVLayer.prototype.constructor = AVLayer; var P = AVLayer.prototype; function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } def("source", function () { return __item(this.__info().source); }); + // After Effects exposes the most common Transform properties directly on AVLayer. + // Return the original Property object, not its current value: scripts can keyframe it. + ["anchorPoint", "position", "scale", "rotation", "opacity"].forEach(function (name) { + def(name, function () { return this.transform[name]; }); + }); def("width", function () { return this.__info().width; }); def("height", function () { return this.__info().height; }); def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); }); @@ -1333,7 +1382,7 @@ LayerCollection.prototype = { var ids = this.__ids(), sel = []; for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]); var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" }); - return new CompItem(r.comp); + return __item(r.comp); }, byName: function (name) { var ids = this.__ids(); @@ -1381,13 +1430,20 @@ ItemCollection.prototype = { function __item(id) { if (id === null || id === undefined) return null; - if (id === 0) return new FolderItem(0); - var i = __get("item", { id: id }); - switch (i.kind) { - case "comp": return new CompItem(id); - case "folder": return new FolderItem(id); - default: return new FootageItem(id); + if (Object.prototype.hasOwnProperty.call(__itemCache, id)) return __itemCache[id]; + var item; + if (id === 0) { + item = new FolderItem(0); + } else { + var i = __get("item", { id: id }); + switch (i.kind) { + case "comp": item = new CompItem(id); break; + case "folder": item = new FolderItem(id); break; + default: item = new FootageItem(id); + } } + __itemCache[id] = item; + return item; } function Item() {} @@ -1417,7 +1473,7 @@ Item.prototype = { __call("project.select", { items: selection.filter(function (item) { return item !== id; }) }); } }, - get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); }, + get usedIn() { return this.__info().usedIn.map(function (c) { return __item(c); }); }, remove: function () { __call("project.delete", { items: [this.__id] }); }, }; @@ -1644,7 +1700,7 @@ RenderQueueItem.prototype = { for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i]; throw __err("the render queue item no longer exists"); }, - get comp() { return new CompItem(this.__info().comp); }, + get comp() { return __item(this.__info().comp); }, get status() { var s = this.__info().statusLabel; for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k]; @@ -1734,7 +1790,11 @@ function ImportOptions(file) { this.rangeStart = 0; this.rangeEnd = 0; } -ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; }; +ImportOptions.prototype.canImportAs = function (t) { + if (t === ImportAsType.FOOTAGE) return true; + return (t === ImportAsType.COMP || t === ImportAsType.COMP_CROPPED_LAYERS) && + /\.(psd|psb|pdf|ai|eps)$/i.test(__str(this.file)); +}; function Project() {} Project.prototype = { @@ -1742,7 +1802,7 @@ Project.prototype = { __info: function () { return __get("project"); }, get items() { return new ItemCollection(0); }, get numItems() { return this.__info().items.length; }, - get rootFolder() { return new FolderItem(0); }, + get rootFolder() { return __item(0); }, get activeItem() { return __item(this.__info().active); }, get selection() { return this.__info().selection.map(__item); }, get file() { var p = this.__info().path; return p ? new File(p) : null; }, @@ -1788,10 +1848,19 @@ Project.prototype = { if (!f) throw __err("importFile(): no file"); var args = { paths: [__str(f)] }; // After Effects imports just the picked file unless ImportOptions.sequence is set. - if (opts instanceof ImportOptions) args.sequence = !!opts.sequence; + if (opts instanceof ImportOptions) { + args.sequence = !!opts.sequence; + switch (opts.importAs) { + case ImportAsType.FOOTAGE: args.importAs = "footage"; break; + case ImportAsType.COMP: args.importAs = "composition"; break; + case ImportAsType.COMP_CROPPED_LAYERS: args.importAs = "compositionLayerSizes"; break; + default: throw __err("importFile(): unsupported ImportOptions.importAs value " + opts.importAs); + } + } var r = __call("file.import", args); if (r.errors && r.errors.length) throw __err(r.errors.join("; ")); - return __item(r.items[0]); + // Composition imports create several footage items, but callers need the comp. + return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]); }, importFileWithDialog: function () { return null; }, importPlaceholder: function (name, w, h, rate, dur) { @@ -1858,8 +1927,13 @@ var app = { beginSuppressDialogs: function () {}, endSuppressDialogs: function () {}, purge: function () { __call("edit.purge", { what: "all" }); }, - findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); }, + findMenuCommandId: function (name) { + // Well-known After Effects ids must not shift when EffectCraft registers a command. + if (String(name) === "Undo") return 16; + return __get("menuCommandId", { name: String(name) }); + }, executeCommand: function (id) { + if (id === 16) return __call("edit.undo", {}); var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id); if (!cmd) throw __err("Unknown command id " + id); return __call(cmd, {}); diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index ffcf90af..c7559856 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -6,6 +6,7 @@ use std::mem::ManuallyDrop; use std::sync::Arc; use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use effectcraft_engine::history::History; use effectcraft_engine::project::Project; use effectcraft_engine::{ScriptRequest, Session}; use serde_json::{Value as J, json}; @@ -63,8 +64,9 @@ impl Outcome { pub(crate) struct Active { pub(crate) session: Session, pub(crate) output: Vec, - /// Open undo groups: (name, project snapshot when the outermost group began). - pub(crate) groups: Vec<(String, Arc)>, + /// Open groups: name, project snapshot, original bounded history and saved-project + /// identity. The history snapshot preserves earlier steps if long groups hit the limit. + pub(crate) groups: Vec<(String, Arc, History, Arc)>, pub(crate) name: String, /// The script context running (its `app.scheduleTask` tasks are kept under this id, see /// [`crate::tasks`]). @@ -240,12 +242,18 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let snap = a.session.project.clone(); - a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap)); + a.groups.push(( + if name.is_empty() || name == "undefined" { "Script".into() } else { name }, + snap, + a.session.history.clone(), + a.session.saved_project.clone(), + )); } _ => { if a.groups.len() == 1 { - let (name, snap) = a.groups.pop().unwrap_or_default(); - collapse(&mut a.session, &name, &snap); + if let Some((name, snap, prior, saved)) = a.groups.pop() { + collapse(&mut a.session, &name, &snap, &prior, &saved); + } } else { a.groups.pop(); } @@ -257,17 +265,23 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult) { +/// Fold every command in a group into a single bounded-history entry, even if the +/// oldest intermediate undo state was trimmed before the group ended. +pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc, prior: &History, saved: &Arc) { let h = &mut s.history; - let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { - // Nothing was recorded (or history was reset by New / Open Project). - return; - }; - let first = h.undo[start].1.clone(); - h.undo.truncate(start); - h.undo.push((name.to_string(), first)); - h.merge_key = None; + if let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) { + // The group is short enough that its opening snapshot is still in history. + let first = h.undo[start].1.clone(); + h.undo.truncate(start); + h.undo.push((name.to_string(), first)); + h.merge_key = None; + } else if !Arc::ptr_eq(&s.project, snap) && Arc::ptr_eq(&s.saved_project, saved) && !h.undo.is_empty() { + // The group exceeded Levels of Undo. Restore the pre-group history (including + // older steps and redo branches) and record one step for the *whole* group. + // A New / Open Project changes saved_project; never resurrect the old history. + *h = prior.clone(); + h.record(name, snap.clone(), s.prefs.general.undo_levels.max(1) as usize); + } } /// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see @@ -346,8 +360,14 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result Result, String> { + data.chars().map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32))).collect() +} + +/// `__file(op, path, data)`: text and one-byte-per-character BINARY reads/writes, +/// byte-for-byte copy, plus file and folder operations. fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; let path = arg_string(args, 1, ctx)?; @@ -366,19 +386,36 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; - Ok(json!(String::from_utf8_lossy(&bytes))) + if op == "readBinary" { + // Each byte becomes a single JS character (U+0000..U+00FF). + Ok(json!(bytes.into_iter().map(char::from).collect::())) + } else { + Ok(json!(String::from_utf8_lossy(&bytes))) + } } - "write" | "append" => { + "write" | "writeBinary" | "append" => { let p = check_access(s, &path, true)?; let ps = p.to_string_lossy().to_string(); let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] }; - bytes.extend_from_slice(data.as_bytes()); + if op == "writeBinary" { + bytes.extend(binary_bytes(&data)?); + } else { + bytes.extend_from_slice(data.as_bytes()); + } s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?; Ok(json!(bytes.len())) } + "copy" => { + let src = check_access(s, &path, false)?; + let dst = check_access(s, &data, true)?; + // Copy the original bytes, not UTF-8-decoded script text. + let bytes = s.services.read_file(&src.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; + s.services.write_file(&dst.to_string_lossy(), &bytes).map_err(|e| format!("Unable to write {data}: {e}"))?; + Ok(json!(true)) + } "list" => { let p = check_access(s, &path, false)?; let mut names: Vec = std::fs::read_dir(&p) @@ -591,8 +628,8 @@ impl Drop for Restore<'_> { /// and take the output so far. pub(crate) fn settle() -> Vec { with_active(|a| { - if let Some((name, snap)) = a.groups.first().cloned() { - collapse(&mut a.session, &name, &snap); + if let Some((name, snap, prior, saved)) = a.groups.first().cloned() { + collapse(&mut a.session, &name, &snap, &prior, &saved); a.groups.clear(); } a.session.sanitize_state(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index b50a7bfb..303bd820 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -730,3 +730,283 @@ fn import_options_sequence_picks_one_still_or_the_run() { assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn binary_file_io_preserves_all_byte_values() { + let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("sig.bin"); + let dst = dir.join("written.bin"); + let copied = dir.join("copied.bin"); + let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a]; + std::fs::write(&src, original).unwrap(); + let mut s = session(); + s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string()); + s.prefs.scripting.allow_scripts_write_files = true; + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + + let read = ok( + &mut s, + &format!( + "var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]", + q(&src) + ), + ); + assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff])); + + ok( + &mut s, + &format!( + "var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();", + q(&dst) + ), + ); + assert_eq!(std::fs::read(&dst).unwrap(), original); + + let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied))); + assert_eq!(copy.result, json!(true)); + assert_eq!(std::fs::read(&copied).unwrap(), original); + + ok(&mut s, &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();", q(&dst))); + assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat()); + + let invalid = run_code( + &mut s, + &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)), + "invalid-binary.jsx", + ); + assert!(invalid.error.is_some(), "code points above 255 must not be truncated"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_undo_uses_stable_after_effects_command_id() { + let mut s = session(); + let lookup = ok(&mut s, "app.findMenuCommandId('Undo')"); + assert_eq!(lookup.result, json!(16)); + // ID 16 used to index an unrelated command as the registry grew. It must + // undo the preceding layer creation rather than dispatching by position. + let r = ok(&mut s, "var c = app.project.items.addComp('Undo ID', 100, 100, 1, 1, 24); c.layers.addNull(); app.executeCommand(16); c.numLayers"); + assert_eq!(r.result, json!(0)); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0); + // Lookup of other command names still uses the existing registry. + assert!(ok(&mut s, "app.findMenuCommandId('New Project')").result.as_u64().unwrap_or(0) > 0); +} + +#[test] +fn long_script_undo_group_counts_as_one_level() { + let mut s = session(); + s.prefs.general.undo_levels = 3; + ok(&mut s, "var c = app.project.items.addComp('Long Undo', 100, 100, 1, 1, 24); c.layers.addNull();"); + assert_eq!(s.history.undo.len(), 2); + // More edits than the history cap must still be one undoable group. + ok(&mut s, "var c = app.project.item(1); app.beginUndoGroup('Forty layers'); for (var i = 0; i < 40; i++) c.layers.addNull(); app.endUndoGroup();"); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 41); + assert_eq!(s.history.undo.len(), 3); + assert_eq!(s.history.undo[2].0, "Forty layers"); + s.undo(); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 1); + // Entries preceding the group remain available instead of being trimmed by + // the forty intermediate operations. + s.undo(); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0); +} + +#[test] +fn script_remove_keys_batches_a_range_as_one_edit() { + let mut s = session(); + let o = ok( + &mut s, + "var c = app.project.items.addComp('Batch Keys', 100, 100, 1, 10, 25); var p = c.layers.addNull().transform.rotation; for (var i = 0; i < 5; i++) p.setValueAtTime(i, i * 10); p.removeKeys(2, 4); [p.numKeys, p.keyTime(1), p.keyTime(2)]", + ); + assert_eq!(o.result, json!([2, 0, 4])); + // The entire deletion is one undo step; keys outside the range survive. + s.undo(); + let restored = ok(&mut s, "app.project.item(1).layer(1).transform.rotation.numKeys"); + assert_eq!(restored.result, json!(5)); + let invalid = run_code(&mut s, "app.project.item(1).layer(1).transform.rotation.removeKeys(0, 2)", "remove-keys-range.jsx"); + assert!(invalid.error.is_some()); +} + +#[test] +fn script_layer_comment_and_unnamed_marker_params_round_trip() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Notes", 100, 100, 1, 5, 25); + var l = c.layers.addSolid([1, 0, 0], "Solid", 10, 10, 1); + l.comment = "Saved layer note"; + var markers = l.property("ADBE Marker"); + var plain = new MarkerValue("Plain"); + plain.setParameters({ lml: "shot", index: "1" }); + markers.setValueAtTime(1, plain); + var named = new MarkerValue("Named"); + named.cuePointName = "cue"; + named.setParameters({ lml: "shot", index: "2" }); + markers.setValueAtTime(2, named); + [l.comment, markers.keyValue(1).getParameters().index, + markers.keyValue(1).cuePointName, markers.keyValue(2).getParameters().index] + "#, + ); + assert_eq!(o.result, json!(["Saved layer note", "1", "", "2"])); +} + +#[test] +fn script_av_layer_transform_shortcuts_return_live_properties() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Shortcuts", 100, 100, 1, 5, 25); + var l = c.layers.addSolid([1, 0, 0], "S", 10, 10, 1); + l.position.setValue([30, 40]); + l.rotation.setValueAtTime(1, 45); + [l.position.value[0], l.transform.position.value[1], l.rotation.numKeys, + l.opacity === undefined, l.scale === undefined, l.anchorPoint === undefined] + "#, + ); + assert_eq!(o.result, json!([30, 40, 1, false, false, false])); + s.undo(); + let restored = ok(&mut s, "app.project.item(1).layer(1).rotation.numKeys"); + assert_eq!(restored.result, json!(0)); +} + +#[test] +fn extendscript_substr_keeps_start_and_length_semantics() { + let mut s = session(); + let o = ok( + &mut s, + r#"[ + "abcdef".substr(0, 2), + "abcdef".substr(2, 3), + "abcdef".substr(-2), + "abcdef".substr(-20, 2), + "abcdef".substr(2, 0), + "abcdef".substr(2, -3), + "abcdef".substr(2.9, 2.9), + "abcdef".substr(3), + "abcdef".substr(30, 2), + "abcdef".substr(NaN, 2), + "abcdef".substr(2, NaN) + ]"#, + ); + assert_eq!(o.result, json!(["ab", "cde", "ef", "ab", "", "", "cd", "def", "", "ab", ""])); +} + +#[test] +fn import_options_preserve_layered_psd_compositions() { + use effectcraft_psd::Rect; + use effectcraft_psd::write::{WDoc, WLayer, write}; + + let dir = std::env::temp_dir().join(format!("ec-script-import-as-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("layers.psd"); + let mut doc = WDoc::new(12, 12); + doc.layers.push(WLayer::solid("Background", Rect::new(0, 0, 12, 12), [1.0, 1.0, 1.0, 1.0])); + doc.layers.push(WLayer::solid("Accent", Rect::new(2, 2, 4, 4), [0.0, 0.4, 1.0, 1.0])); + std::fs::write(&source, write(&doc)).unwrap(); + let file = serde_json::to_string(&source.to_string_lossy()).unwrap(); + let mut s = session(); + let o = ok( + &mut s, + &format!( + "var io = new ImportOptions(new File({file})); io.importAs = ImportAsType.COMPOSITION_CROPPED_LAYERS; \ + [io.canImportAs(io.importAs), app.project.importFile(io).typeName]" + ), + ); + assert_eq!(o.result, json!([true, "Composition"])); + let imported = s.project.comps().next().unwrap().1; + assert_eq!(imported.layers.len(), 2); + let bad = ok(&mut s, &format!("new ImportOptions(new File({file})).canImportAs(ImportAsType.PROJECT)")); + assert_eq!(bad.result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_item_and_layer_identity_survives_repeated_access() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Identity", 120, 80, 1, 3, 25); + var parent = c.layers.addNull(), child = c.layers.addNull(); + child.parent = parent; + [ + parent.containingComp === c, + c.layer(1) === c.layer(1), + child.parent === parent, + app.project.item(1) === app.project.item(1), + app.project.item(1) === c, + app.project.itemByID(c.id) === c + ] + "#, + ); + assert_eq!(o.result, json!([true, true, true, true, true, true])); + // Reusing item and layer IDs in a new project must not alias old wrappers. + let o = ok( + &mut s, + r#" + var old = app.project.item(1); + app.newProject(); + var next = app.project.items.addComp("New", 120, 80, 1, 3, 25); + [old !== next, app.project.item(1) === next] + "#, + ); + assert_eq!(o.result, json!([true, true])); +} + +#[test] +fn script_text_path_options_expose_after_effects_match_names() { + let mut s = session(); + let r = ok( + &mut s, + r#" + var c = app.project.items.addComp("Text Path", 160, 90, 1, 2, 24); + var t = c.layers.addText("On a path"); + var options = t.property("ADBE Text Properties").property("ADBE Text Path Options"); + var names = []; + for (var i = 1; i <= options.numProperties; i++) + names.push(options.property(i).matchName); + [names, options.property("ADBE Text Path").matchName, + options.property("ADBE Text Perpendicular To Path").matchName] + "#, + ); + assert_eq!( + r.result, + json!([ + [ + "ADBE Text Path", + "ADBE Text Reverse Path", + "ADBE Text Perpendicular To Path", + "ADBE Text Force Align Path", + "ADBE Text First Margin", + "ADBE Text Last Margin" + ], + "ADBE Text Path", + "ADBE Text Perpendicular To Path" + ]) + ); +} + +/// A scripted text animator has only the selectors the script adds, and the Range Selector and +/// its Start/End/Offset resolve by After Effects match name (#837). +#[test] +fn scripted_text_range_selector_resolves_and_is_the_only_selector() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Sel", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + a.property("ADBE Text Animator Properties").addProperty("ADBE Text Opacity").setValue(0); + var sels = a.property("ADBE Text Selectors"); + var sel = sels.addProperty("ADBE Text Selector"); + sel.property("ADBE Text Percent Start").setValue(50); + var end = sel.property("ADBE Text Percent End").value; + [sels.numProperties, sel.matchName, sel.property("start").value, end, sel.property("ADBE Text Percent Offset").matchName] + "#, + ); + assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 6bef8d63..75985fbd 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -794,10 +794,10 @@ impl<'a, 'i> Parser<'a, 'i> { let vb = viewbox_transform(n, w, h); let mut children = vec![]; let saved = (self.vw, self.vh); - if let Some((_, vw, vh)) = viewbox(n) { - self.vw = vw; - self.vh = vh; - } + // Percentages use this viewport's user space, even without a viewBox. + let (vw, vh) = viewbox(n).map_or((w, h), |(_, vw, vh)| (vw, vh)); + self.vw = vw; + self.vh = vh; for c in n.children() { self.node(c, &st, &mut children, depth + 1); } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index a7a9bef3..3553205d 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -86,6 +86,39 @@ fn paths_gradients_css_use() { assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}"); } +#[test] +fn nested_svg_percentages_use_the_nearest_viewport() { + let d = parse( + br#" + + + + + + + + + + + "#, + ) + .unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 4); + for ((_, _, shape), (name, width, height)) in + shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)]) + { + assert_eq!(shape.name, name); + assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom); + } + let img = rasterize(&d, 100, 80, 1.0); + assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&img, 35, 17)[3], 0.0); + assert_eq!(px(&img, 45, 65)[3], 1.0); + assert_eq!(px(&img, 55, 65)[3], 0.0); +} + #[test] fn malformed_input() { assert!(parse(b"").is_err()); diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs new file mode 100644 index 00000000..39714cad --- /dev/null +++ b/crates/text/src/font_dirs.rs @@ -0,0 +1,374 @@ +//! The font folders of a Linux/BSD system, found the way the desktop finds them: the usual +//! folders, the XDG data folders (`$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`), the host font +//! folders a Flatpak sandbox exposes under `/run/host`, and the `` entries of the fontconfig +//! configuration (`/etc/fonts/fonts.conf` with its includes, `~/.config/fontconfig/fonts.conf`). +//! +//! The fontconfig files are read with a small tag scanner (no fontconfig library, no XML crate): +//! only ``, `` and `` matter here. The work is bounded: include depth, +//! number and size of the files read, and every file is read at most once. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// The environment the folders depend on, passed in so tests don't touch the process environment. +#[derive(Clone, Debug, Default)] +pub(crate) struct FontEnv { + pub home: Option, + pub xdg_data_home: Option, + pub xdg_data_dirs: Option, + pub xdg_config_home: Option, + /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. + pub fontconfig_file: Option, + /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). + pub fontconfig_roots: Vec, + /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). + pub fixed: Vec, +} + +impl FontEnv { + pub fn from_process() -> Self { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + Self { + home: var("HOME").map(PathBuf::from), + xdg_data_home: var("XDG_DATA_HOME").map(PathBuf::from), + xdg_data_dirs: var("XDG_DATA_DIRS"), + xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), + fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), + fixed: [ + "/usr/share/fonts", + "/usr/local/share/fonts", + // Flatpak: the host's system, local and user fonts (no filesystem permission needed). + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/user-fonts", + ] + .map(PathBuf::from) + .to_vec(), + } + } + + fn data_home(&self) -> Option { + self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share"))) + } + + fn config_home(&self) -> Option { + self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + } +} + +/// Every font folder for `env`, without duplicates, in a stable order. Folders that don't exist +/// are kept (the scan skips them). +pub(crate) fn unix_font_dirs(env: &FontEnv) -> Vec { + let mut dirs = env.fixed.clone(); + if let Some(h) = &env.home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + if let Some(d) = env.data_home() { + dirs.push(d.join("fonts")); + } + let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| p.join("fonts"))); + dirs.extend(fontconfig_dirs(env)); + dedup_dirs(dirs) +} + +/// Drop repeated folders (also the same folder reached through a symlink), keeping the first. +fn dedup_dirs(dirs: Vec) -> Vec { + let mut seen = HashSet::new(); + dirs.into_iter().filter(|d| seen.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()))).collect() +} + +const MAX_INCLUDE_DEPTH: u32 = 8; +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; + +/// The font folders named by the fontconfig configuration. +pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { + let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; + let root = match &env.fontconfig_file { + Some(f) => Some(f.clone()), + None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), + }; + if let Some(root) = root { + st.file(&root, 0); + } + // The user's own configuration, in case the system one doesn't include it. + if let Some(c) = env.config_home() { + st.include(&c.join("fontconfig/fonts.conf"), 0); + st.include(&c.join("fontconfig/conf.d"), 0); + } + st.out +} + +struct Reader<'a> { + env: &'a FontEnv, + out: Vec, + seen: HashSet, +} + +impl Reader<'_> { + /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. + fn include(&mut self, path: &Path, depth: u32) { + if path.is_dir() { + let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth); + } + } else { + self.file(path, depth); + } + } + + fn file(&mut self, path: &Path, depth: u32) { + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.seen.insert(key) { + return; + } + if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); + for el in elements(&text) { + match el.name { + "dir" | "remap-dir" => { + if let Some(p) = self.resolve(&el, &base, false) { + self.out.push(p); + } + } + "include" => { + if let Some(p) = self.resolve(&el, &base, true) { + self.include(&p, depth.saturating_add(1)); + } + } + _ => {} + } + } + } + + /// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders) + /// or config (includes) home, `prefix="relative"` and plain relative paths the including + /// file's folder, `prefix="default"`/`"cwd"` the working folder. + fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option { + let v = el.text.trim(); + if v.is_empty() { + return None; + } + if let Some(rest) = v.strip_prefix('~') { + return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let p = Path::new(v); + if p.is_absolute() { + return Some(p.to_path_buf()); + } + match el.attr("prefix") { + Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)), + Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)), + _ => Some(base.join(p)), + } + } +} + +struct Element<'a> { + name: &'a str, + attrs: &'a str, + text: String, +} + +impl Element<'_> { + fn attr(&self, key: &str) -> Option<&str> { + let mut rest = self.attrs; + while let Some(eq) = rest.find('=') { + let k = rest.get(..eq)?.trim(); + let after = rest.get(eq + 1..)?.trim_start(); + let q = after.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let body = after.get(1..)?; + let end = body.find(q)?; + if k == key { + return body.get(..end); + } + rest = body.get(end + 1..)?; + } + None + } +} + +/// The ``, `` and `` elements of a fontconfig file, comments skipped. +fn elements(src: &str) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(off) = src.get(i..).and_then(|s| s.find('<')) { + let start = i + off; + let Some(tail) = src.get(start..) else { break }; + if tail.starts_with("") { + Some(e) => { + i = start + e + 3; + continue; + } + None => break, + } + } + let Some(close) = tail.find('>') else { break }; + let inner = tail.get(1..close).unwrap_or_default(); + i = start + close + 1; + let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len()); + let name = inner.get(..name_len).unwrap_or_default(); + if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') { + continue; + } + let attrs = inner.get(name_len..).unwrap_or_default(); + let end_tag = format!(""); + let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break }; + let body = src.get(i..i + body_len).unwrap_or_default(); + i += body_len + end_tag.len(); + out.push(Element { name, attrs, text: decode_entities(body) }); + } + out +} + +fn decode_entities(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-font-dirs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn env_in(root: &Path) -> FontEnv { + FontEnv { + home: Some(root.join("home")), + fontconfig_roots: vec![root.join("etc/fonts/fonts.conf")], + xdg_data_dirs: Some("/nonexistent-share".into()), + ..Default::default() + } + } + + fn write(p: &Path, s: &str) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, s).unwrap(); + } + + #[test] + fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() { + let root = temp("conf"); + let etc = root.join("etc/fonts"); + write( + &etc.join("fonts.conf"), + r#" + + + + /sys/fonts + fonts + ~/.fonts + /var/cache/fontconfig + fontconfig + local-fonts + /a&b/fonts + conf.d + missing.conf + fontconfig/fonts.conf + fonts.conf +"#, + ); + write( + &etc.join("conf.d/50-flatpak.conf"), + "/run/host/fonts/run/host/font-dirs.xml", + ); + write(&etc.join("conf.d/60-remap.conf"), "/remapped/fonts"); + write(&etc.join("conf.d/README"), "/not/a/conf/file"); + write(&etc.join("conf.d/nodigit.conf"), "/no/digit"); + write(&root.join("home/.config/fontconfig/fonts.conf"), "/user/conf/fonts"); + let dirs = fontconfig_dirs(&env_in(&root)); + let home = root.join("home"); + let want = vec![ + PathBuf::from("/sys/fonts"), + home.join(".local/share/fonts"), + home.join(".fonts"), + etc.join("local-fonts"), + PathBuf::from("/a&b/fonts"), + PathBuf::from("/run/host/fonts"), + PathBuf::from("/remapped/fonts"), + PathBuf::from("/user/conf/fonts"), + ]; + assert_eq!(dirs, want); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn include_cycles_and_unclosed_tags_terminate() { + let root = temp("cycle"); + let etc = root.join("etc/fonts"); + write(&etc.join("fonts.conf"), "/oneb.conffonts.conf"); + write(&etc.join("b.conf"), "fonts.conf/two/unclosed"); + let dirs = fontconfig_dirs(&env_in(&root)); + assert_eq!(dirs, vec![PathBuf::from("/one"), PathBuf::from("/two")]); + assert!(fontconfig_dirs(&FontEnv::default()).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn xdg_and_flatpak_folders_are_listed_once() { + let root = temp("xdg"); + let env = FontEnv { + home: Some(root.join("home")), + xdg_data_home: Some(root.join("data")), + xdg_data_dirs: Some(std::env::join_paths([root.join("share"), "relative/ignored".into()]).unwrap()), + fixed: vec![PathBuf::from("/run/host/fonts")], + fontconfig_file: Some(root.join("fonts.conf")), + ..Default::default() + }; + write(&root.join("fonts.conf"), "/run/host/fontsfonts"); + let dirs = unix_font_dirs(&env); + assert_eq!( + dirs, + vec![ + PathBuf::from("/run/host/fonts"), + root.join("home/.local/share/fonts"), + root.join("home/.fonts"), + root.join("data/fonts"), + root.join("share/fonts"), + ] + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A font in a folder that only the fontconfig configuration names (as in a Flatpak sandbox, + /// where the host's fonts are under /run/host) is found by the scan. + #[test] + fn a_font_named_only_by_fontconfig_is_scanned() { + use crate::fonts::FontSource; + let root = temp("e2e"); + let host = root.join("run/host/fonts/TTF"); + std::fs::create_dir_all(&host).unwrap(); + std::fs::write(host.join("NOTOSERIF.TTF"), crate::fonts::NOTO_SERIF_REGULAR).unwrap(); + write(&root.join("etc/fonts/conf.d/50-flatpak.conf"), &format!("{}", root.join("run/host/fonts").display())); + write(&root.join("etc/fonts/fonts.conf"), "conf.d"); + let src = crate::fonts::DirectorySource { dirs: unix_font_dirs(&env_in(&root)) }; + let faces = src.faces(); + assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 1df8b337..4091344d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -89,13 +89,11 @@ impl DirectorySource { if let Some(l) = std::env::var_os("LOCALAPPDATA") { dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); } - } else if cfg!(unix) && !cfg!(target_arch = "wasm32") { - dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); - if let Some(h) = &home { - dirs.push(h.join(".local/share/fonts")); - dirs.push(h.join(".fonts")); - } } + // Linux/BSD: the usual folders, the XDG data folders, a Flatpak sandbox's host font + // folders (/run/host/…) and the folders the fontconfig configuration names. + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + dirs.extend(crate::font_dirs::unix_font_dirs(&crate::font_dirs::FontEnv::from_process())); dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref())); Self { dirs } } @@ -112,7 +110,8 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { - if depth < 4 { + // Bounded, so a symlink loop ends. + if depth < 8 { walk(&p, depth + 1, out); } } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 21bb12b1..8b7ca771 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod font_dirs; pub mod fonts; pub mod layout; pub mod path_text; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 724f3402..113c8bc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -223,8 +223,16 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(z) = v.get("zoom") { app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); } + // The active comp's Resolution (`view.res.*`), saved with the project. if let Some(r) = v.get("res").and_then(Value::as_str) { - app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).unwrap_or(app.ui.viewer.res); + let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else { + return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)")); + }; + if let Some(cid) = app.session.active_comp_id() + && let Err(e) = app.session.set_resolution(cid, res) + { + return err(e); + } } if let Some(Value::Array(a)) = v.get("pan") { app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32]; @@ -308,6 +316,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques Err(e) => return err(e), }; let m = modifiers(p); + // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) + // and the synthetic events arrive one per frame: hold them for the whole gesture. + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -321,6 +332,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -332,6 +344,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -339,6 +352,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -522,11 +536,16 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str()); json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates}) }); + let mut ui = json!(app.ui); + // The viewer's Resolution is the active comp's (kept in the project). + if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) { + v.insert("res".into(), json!(app.viewer_res())); + } json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, - "ui": app.ui, + "ui": ui, "playing": app.playback.playing, "time": app.session.time().seconds(), "activeComp": app.session.state.active_comp.map(|i| i.0), diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 3e802487..80d68f8e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -670,6 +670,37 @@ pub struct Floating { pub rect: [f32; 4], } +/// A floating group moved out of the main window into its own native window (which can sit on +/// another monitor): the group holding `panel` shows there. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PanelWindow { + pub panel: PanelKind, + /// The window's [x, y] outer position and [w, h] inner size, in desktop points. + pub rect: [f32; 4], +} + +/// The smallest and largest native panel window, in points. +pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0]; +pub const PANEL_WINDOW_MAX: f32 = 16_384.0; +/// The farthest a native panel window may sit from the desktop origin, in points. +pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0; + +impl PanelWindow { + /// The rect made safe to hand to the windowing system: finite, with a size within + /// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within + /// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included). + pub fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX), + finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX), + ] + } +} + impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s @@ -1305,10 +1336,13 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } } } - // clicking anywhere in the panel focuses it + // clicking anywhere in the panel focuses it; a press on another of its tabs waits for that + // tab's click, which focuses the panel actually chosen (a text edit stays open in Character) + let at = ui.input(|i| i.pointer.interact_pos()); if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) + && !out.tabs.iter().any(|(q, r)| *q != p && at.is_some_and(|a| r.contains(a))) { out.actions.push(DockAction::Focus(p)); } diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9c28f353..de971bdc 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -2,11 +2,13 @@ //! and body, floating (undocked) panel groups over it, and tab drag-and-drop: drag a tab onto a //! group to tab it there (center) or split it off to a side (After Effects' drop zones, with a //! highlight of where it will land); hold Cmd/Ctrl when releasing, or drop outside every group, -//! to float it. `` ` `` maximizes the panel under the pointer. +//! to float it. `` ` `` maximizes the panel under the pointer. A floating group can also move +//! out of the main window into its own native window ([`EffectcraftApp::set_panel_window`]), +//! which can sit on another monitor. use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind}; +use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, PanelWindow}; use crate::{EffectcraftApp, panels}; use craft_ui::docking::{Action, DropTarget, resolve_drop}; @@ -245,7 +247,15 @@ impl EffectcraftApp { }) .unwrap_or(usize::MAX) }); + // A native window is kept only for a panel that still floats. + let ui_state = &mut self.ui; + ui_state.panel_windows.retain(|w| ui_state.floating.iter().any(|group| group.panels.contains(&w.panel))); for i in floating_order { + // Groups in their own native window take no tab drops from the main window. + if let Some(window) = self.ui.floating.get(i).and_then(|group| self.panel_window_of(&group.panels)) { + self.panel_window(&ctx, i, window, &info, &mut actions); + continue; + } if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) { tab_rects.push((g.path.clone(), tabs)); float_groups.push(g); @@ -264,6 +274,7 @@ impl EffectcraftApp { } DockAction::Focus(p) => { self.ui.focused = p; + panels::viewer_text::panel_clicked(self, &ctx, p); // A click in (or on the tab of) another Composition viewer makes it active. if let Some(id) = panels::viewers::id_of(p) { panels::viewers::activate(self, &ctx, id); @@ -376,6 +387,135 @@ impl EffectcraftApp { group } + /// The native window a floating group (its `panels`) shows in, if any. + pub(crate) fn panel_window_of(&self, panels: &[PanelKind]) -> Option { + self.ui.panel_windows.iter().find(|w| panels.contains(&w.panel)).copied() + } + + /// Whether panel `p` shows in its own native window. + pub fn in_panel_window(&self, p: PanelKind) -> bool { + self.ui.floating.iter().any(|group| group.panels.contains(&p) && self.panel_window_of(&group.panels).is_some()) + } + + /// The viewport panel `p` is drawn in: its native window's, or the main window's (also when + /// the platform embeds viewports, as the browser build does). + pub(crate) fn panel_viewport(&self, ctx: &egui::Context, p: PanelKind) -> egui::ViewportId { + if ctx.embed_viewports() { + return egui::ViewportId::ROOT; + } + self.ui + .floating + .iter() + .find(|group| group.panels.contains(&p)) + .and_then(|group| self.panel_window_of(&group.panels)) + .map_or(egui::ViewportId::ROOT, |w| panel_window_id(w.panel)) + } + + /// Move panel `p` (and the floating group it is in) into its own native window, which can be + /// placed on another monitor, or back into the main window (`window` false; it floats there + /// where it was before). `None` toggles. A docked panel is undocked first. The window opens + /// over the place the panel had in the main window. Returns whether `p` is now in a window. + pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option) -> Result { + let group_of = |app: &Self| app.ui.floating.iter().find(|group| group.panels.contains(&p)).map(|group| (group.panels.clone(), group.rect)); + let current = self.in_panel_window(p); + let window = window.unwrap_or(!current); + if window == current { + return Ok(current); + } + if !window { + if let Some((panels, _)) = group_of(self) { + self.ui.panel_windows.retain(|w| !panels.contains(&w.panel)); + } + return Ok(false); + } + if group_of(self).is_none() { + if !self.ui.dock.contains(p) { + return Err(format!("{} is not open", p.title())); + } + let rect = default_float_rect(ctx.input_for(egui::ViewportId::ROOT, |i| i.content_rect())); + if !self.edit_layout(|layout| layout.float(p, rect)) { + return Err(format!("can't undock {}", p.title())); + } + } + let Some((_, [x, y, w, h])) = group_of(self) else { return Err(format!("can't undock {}", p.title())) }; + // Over the panel's place in the main window (its content's position on the desktop). + let origin = ctx.input_for(egui::ViewportId::ROOT, |i| i.viewport().inner_rect.map(|r| r.min)).unwrap_or(pos2(0.0, 0.0)); + let rect = PanelWindow::sanitized([origin.x + x, origin.y + y, w, h]); + ctx.data_mut(|d| d.remove::<[f32; 4]>(panel_window_spawn_id(p))); + self.ui.panel_windows.push(PanelWindow { panel: p, rect }); + if self.ui.maximized == Some(p) { + self.ui.maximized = None; + } + Ok(true) + } + + /// One floating group in its own native window: the group's tab strip and active panel fill + /// the window. Closing the window brings the group back into the main window. + fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo, actions: &mut Vec) { + let t = self.tokens; + let Some(f) = self.ui.floating.get(i).cloned() else { return }; + let active = f.active.min(f.panels.len().saturating_sub(1)); + let Some(shown) = f.panels.get(active).copied() else { return }; + // The window opens at its saved place; after that the window system owns its position and + // size (handing them back every frame would fight the user dragging it). + let spawn_id = panel_window_spawn_id(window.panel); + let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_id, || PanelWindow::sanitized(window.rect))); + let builder = egui::ViewportBuilder::default() + .with_title((info.title)(shown)) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(dock::PANEL_WINDOW_MIN); + let mut closed = false; + let mut placed = None; + ctx.show_viewport_immediate(panel_window_id(window.panel), builder, |ui, class| { + let native = matches!(class, egui::ViewportClass::Immediate | egui::ViewportClass::Deferred); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| { + let r = ui.max_rect(); + let g = Group { + path: format!("w{i}"), + rect: r, + content: Rect::from_min_max(pos2(r.min.x, (r.min.y + t.tab_h).min(r.max.y)), r.max), + panels: f.panels.clone(), + active, + stacked: None, + }; + let c = dock::draw_group_chrome(ui, &g, &t, &mut self.auto, info); + // Tabs can't be dragged between native windows yet. + actions.extend(c.actions.into_iter().filter(|a| !matches!(a, DockAction::BeginDrag(_)))); + self.panel_body(ui, shown, g.content); + if shown == self.ui.focused { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + if native { + panels::panel_menu_popup(self, ui); + } + }); + // An embedded window (no native windows on this platform) reads the main window's + // input: its close request and place aren't this window's. + if native { + closed = ui.input(|i| i.viewport().close_requested()); + placed = ui.input(|i| { + let v = i.viewport(); + let inner = v.inner_rect?; + let min = v.outer_rect.map_or(pos2(window.rect[0], window.rect[1]), |outer| outer.min); + Some([min.x, min.y, inner.width(), inner.height()]) + }); + } + }); + if closed { + self.ui.panel_windows.retain(|w| !f.panels.contains(&w.panel)); + ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_id)); + return; + } + // Remember where the window is, so the workspace reopens it there. + if let Some(rect) = placed.map(PanelWindow::sanitized) + && rect != window.rect + && let Some(w) = self.ui.panel_windows.iter_mut().find(|w| w.panel == window.panel) + { + w.rect = rect; + } + } + /// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab /// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs /// too); near a group's edge, the half it will split off. On release it lands there (or @@ -460,6 +600,16 @@ fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) { } } +/// The native window of the floating group whose window entry names `panel`. +pub fn panel_window_id(panel: PanelKind) -> egui::ViewportId { + egui::ViewportId::from_hash_of(("effectcraft-panel-window", panel.id())) +} + +/// Where a native panel window was told to open (kept for as long as it stays open). +fn panel_window_spawn_id(panel: PanelKind) -> egui::Id { + egui::Id::new(("panel-window-spawn", panel.id())) +} + /// A new floating group's default rect near the centre of `screen`. pub fn default_float_rect(screen: Rect) -> [f32; 4] { let c = screen.center(); diff --git a/crates/ui-egui/src/expression_diagnostics.rs b/crates/ui-egui/src/expression_diagnostics.rs new file mode 100644 index 00000000..93d2495d --- /dev/null +++ b/crates/ui-egui/src/expression_diagnostics.rs @@ -0,0 +1,479 @@ +//! Cached viewer diagnostics. Native scans run on one persistent worker, never while painting. +//! Each viewer keeps only its newest request; obsolete scans stop between properties. On the +//! single-threaded web build, one shared scan runs after interaction settles (no thread fallback). + +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::expr_tools::{self, ExprError}; +use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::render::{EvalCtx, ExprHost, FootageSource}; +use effectcraft_engine::time::Tick; +use web_time::Instant; + +const QUIET: Duration = Duration::from_millis(150); +const MAX_VIEWERS: usize = 8; +type Errors = Arc<[ExprError]>; +type SyntaxCheck = fn(&str) -> Result<(), String>; + +#[derive(Clone)] +struct Snapshot { + project: Arc, + revision: u64, + comp: ItemId, + time: Tick, + expr: Option>, + footage: Arc, + check: Option, +} + +impl Snapshot { + fn new(s: &Session, comp: ItemId) -> Self { + Self { + project: s.project.clone(), + revision: s.revision, + comp, + time: s.time_of(comp), + expr: s.expr.clone(), + footage: s.footage.clone(), + check: s.expr_check, + } + } + + fn matches(&self, s: &Session, comp: ItemId) -> bool { + self.revision == s.revision && self.comp == comp && self.time == s.time_of(comp) && Arc::ptr_eq(&self.project, &s.project) + } +} + +struct Request { + snapshot: Snapshot, + cancelled: Arc, + #[cfg(not(target_arch = "wasm32"))] + repaint: egui::Context, +} + +struct Completed { + #[cfg(not(target_arch = "wasm32"))] + comp: ItemId, + #[cfg(not(target_arch = "wasm32"))] + cancelled: Arc, + result: Result>, String>, +} + +impl Request { + fn run(&self) -> Completed { + let s = &self.snapshot; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let comp = s.project.comp(s.comp)?; + let ctx = EvalCtx { expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()), ..EvalCtx::new(&s.project, s.comp, comp, s.time) }; + expr_tools::errors_in(&ctx, s.check, || !self.cancelled.load(Ordering::Relaxed)) + })) + .map_err(|_| "Expression diagnostics stopped unexpectedly; edit an expression to retry.".to_string()); + Completed { + #[cfg(not(target_arch = "wasm32"))] + comp: s.comp, + #[cfg(not(target_arch = "wasm32"))] + cancelled: self.cancelled.clone(), + result, + } + } +} + +struct Entry { + snapshot: Snapshot, + changed: Instant, + cancelled: Arc, + submitted: bool, + errors: Errors, +} + +impl Drop for Entry { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub(crate) struct Diagnostics { + entries: VecDeque, + #[cfg(not(target_arch = "wasm32"))] + worker: Option, + failure: Option, +} + +impl Diagnostics { + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub fn take_failure(&mut self) -> Option { + self.failure.take() + } + + /// Paint from completed results only. Changes discard stale results immediately, including + /// errors whose layer/property no longer exists. Pointer interaction and playback defer scans. + pub fn errors(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool) -> Errors { + self.update(s, ctx, comp, interacting, Instant::now()) + } + + fn update(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool, now: Instant) -> Errors { + if !s.prefs.scripting.error_banner || s.expr.is_none() { + self.clear(); + return Arc::from([]); + } + let mut entry = self.entries.iter().position(|e| e.snapshot.comp == comp).and_then(|i| self.entries.remove(i)); + if entry.as_ref().is_none_or(|e| !e.snapshot.matches(s, comp)) { + entry = Some(Entry { + snapshot: Snapshot::new(s, comp), + changed: now, + cancelled: Arc::new(AtomicBool::new(false)), + submitted: false, + errors: Arc::from([]), + }); + } + let Some(mut entry) = entry else { return Arc::from([]) }; + if interacting { + entry.changed = now; + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(worker) = &self.worker { + let done: Vec<_> = worker.shared.completed.lock().unwrap_or_else(PoisonError::into_inner).drain(..).collect(); + for done in done { + let target = if done.comp == comp { Some(&mut entry) } else { self.entries.iter_mut().find(|e| e.snapshot.comp == done.comp) }; + if let Some(target) = target + && Arc::ptr_eq(&target.cancelled, &done.cancelled) + && !done.cancelled.load(Ordering::Relaxed) + { + match done.result { + Ok(Some(errors)) => target.errors = errors.into(), + Ok(None) => {} + Err(e) => self.failure = Some(e), + } + } + } + } + if !entry.submitted && !interacting { + let quiet = now.saturating_duration_since(entry.changed); + if quiet < QUIET { + ctx.request_repaint_after(QUIET - quiet); + } else { + entry.submitted = true; + let request = Request { + snapshot: entry.snapshot.clone(), + cancelled: entry.cancelled.clone(), + #[cfg(not(target_arch = "wasm32"))] + repaint: ctx.clone(), + }; + #[cfg(not(target_arch = "wasm32"))] + { + if self.worker.is_none() { + match Worker::new() { + Ok(worker) => self.worker = Some(worker), + Err(e) => self.failure = Some(format!("Could not start expression diagnostics: {e}")), + } + } + if let Some(worker) = &self.worker { + worker.submit(request); + } + } + // wasm32 cannot spawn Rust threads. Preserve diagnostics there, but eliminate + // the duplicate scan and defer it until scrubbing/dragging/playback stops. + #[cfg(target_arch = "wasm32")] + match request.run().result { + Ok(Some(errors)) => entry.errors = errors.into(), + Ok(None) => {} + Err(e) => self.failure = Some(e), + } + } + } + let result = entry.errors.clone(); + self.entries.push_back(entry); + if self.entries.len() > MAX_VIEWERS { + self.entries.pop_front(); + } + result + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct Shared { + pending: Mutex>, + completed: Mutex>, + wake: std::sync::Condvar, + stopped: AtomicBool, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Worker { + shared: Arc, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Worker { + fn new() -> std::io::Result { + let shared = Arc::new(Shared::default()); + let work = shared.clone(); + std::thread::Builder::new().name("ec-expression-diagnostics".into()).spawn(move || { + loop { + let request = { + let mut pending = work.pending.lock().unwrap_or_else(PoisonError::into_inner); + while pending.is_empty() && !work.stopped.load(Ordering::Relaxed) { + pending = work.wake.wait(pending).unwrap_or_else(PoisonError::into_inner); + } + if work.stopped.load(Ordering::Relaxed) { + return; + } + pending.pop_front() + }; + let Some(request) = request else { continue }; + if request.cancelled.load(Ordering::Relaxed) { + continue; + } + let result = request.run(); + if !result.cancelled.load(Ordering::Relaxed) { + let mut completed = work.completed.lock().unwrap_or_else(PoisonError::into_inner); + completed.retain(|r| r.comp != result.comp && !r.cancelled.load(Ordering::Relaxed)); + if completed.len() >= MAX_VIEWERS { + completed.pop_front(); + } + completed.push_back(result); + } + request.repaint.request_repaint(); + } + })?; + Ok(Self { shared }) + } + + fn submit(&self, request: Request) { + let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); + pending.retain(|r| r.snapshot.comp != request.snapshot.comp && !r.cancelled.load(Ordering::Relaxed)); + if pending.len() >= MAX_VIEWERS { + pending.pop_front(); + } + pending.push_back(request); + self.shared.wake.notify_one(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Worker { + fn drop(&mut self) { + // Hold the predicate lock when waking, so shutdown cannot race with entering wait. + let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); + self.shared.stopped.store(true, Ordering::Relaxed); + pending.clear(); + self.shared.wake.notify_one(); + // Do not join an in-flight expression on the UI thread. Entries cancel it on drop. + } +} + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use super::*; + use effectcraft_engine::keyframe::Value; + use effectcraft_engine::project::{Layer, Property}; + use serde_json::json; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc; + + struct Host { + main: std::thread::ThreadId, + on_ui: AtomicUsize, + calls: AtomicUsize, + entered: mpsc::Sender<()>, + gate: (Mutex, std::sync::Condvar), + fail: bool, + } + impl ExprHost for Host { + fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + if std::thread::current().id() == self.main { + self.on_ui.fetch_add(1, Ordering::Relaxed); + return Err("diagnostics evaluated on UI thread".into()); + } + let _ = self.entered.send(()); + let (lock, wake) = &self.gate; + let guard = lock.lock().unwrap(); + let (_guard, timed) = wake.wait_timeout_while(guard, Duration::from_secs(5), |open| !*open).unwrap(); + assert!(!timed.timed_out(), "test did not release the worker"); + if self.fail { Err(format!("failure at {}", ctx.time.0)) } else { Ok(value.clone()) } + } + } + impl Host { + fn release(&self) { + *self.gate.0.lock().unwrap() = true; + self.gate.1.notify_all(); + } + } + fn fixture(fail: bool, blocked: bool) -> (Session, Arc, mpsc::Receiver<()>) { + let (tx, rx) = mpsc::channel(); + let host = Arc::new(Host { + main: std::thread::current().id(), + on_ui: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + entered: tx, + gate: (Mutex::new(!blocked), std::sync::Condvar::new()), + fail, + }); + let mut s = Session { expr: Some(host.clone()), ..Session::default() }; + s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap(); + let hidden = s.execute("layer.newSolid", json!({"name":"Hidden expression"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer":hidden,"path":"transform/opacity","expression":"value"})).unwrap(); + let visible = s.execute("layer.newSolid", json!({"name":"Solo plate"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers":[visible],"switch":"solo","value":true})).unwrap(); + s.execute("layer.select", json!({"layers":[visible]})).unwrap(); + (s, host, rx) + } + fn wait_done(d: &Diagnostics) { + let until = Instant::now() + Duration::from_secs(3); + loop { + if d.worker.as_ref().is_some_and(|w| !w.shared.completed.lock().unwrap().is_empty()) { + return; + } + assert!(Instant::now() < until, "diagnostics did not finish"); + std::thread::sleep(Duration::from_millis(1)); + } + } + fn start(d: &mut Diagnostics, s: &Session, ctx: &egui::Context, now: Instant) { + let cid = s.active_comp_id().unwrap(); + d.update(s, ctx, cid, false, now); + d.update(s, ctx, cid, false, now + QUIET); + } + + #[test] + fn successful_hidden_expressions_run_once_off_the_ui_thread() { + let (s, host, _) = fixture(false, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + wait_done(&d); + for _ in 0..5 { + assert!(d.update(&s, &ctx, cid, false, now + QUIET).is_empty()); + } + assert_eq!(host.calls.load(Ordering::Relaxed), 1); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + } + + #[test] + fn newest_request_replaces_queued_times_and_discards_stale_errors() { + let (mut s, host, entered) = fixture(true, true); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + entered.recv_timeout(Duration::from_secs(3)).unwrap(); + for i in 1..20 { + s.set_time(Tick::from_seconds_f64(i as f64 / 30.)); + start(&mut d, &s, &ctx, now + Duration::from_secs(i)); + assert!(d.entries.back().unwrap().errors.is_empty()); + } + assert_eq!(d.worker.as_ref().unwrap().shared.pending.lock().unwrap().len(), 1); + host.release(); + wait_done(&d); + let errors = d.update(&s, &ctx, cid, false, now + Duration::from_secs(30)); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, format!("failure at {}", s.time().0)); + assert_eq!(host.calls.load(Ordering::Relaxed), 2); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + } + + #[test] + fn warning_preference_and_interaction_prevent_scanning_and_clear_stale_results() { + let (mut s, host, _) = fixture(true, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + for i in 0..10 { + d.update(&s, &ctx, cid, true, now + Duration::from_secs(i)); + } + assert!(d.worker.is_none()); + s.prefs.scripting.error_banner = false; + start(&mut d, &s, &ctx, now + Duration::from_secs(10)); + assert!(d.worker.is_none()); + assert_eq!(host.calls.load(Ordering::Relaxed), 0); + s.prefs.scripting.error_banner = true; + start(&mut d, &s, &ctx, now + Duration::from_secs(11)); + wait_done(&d); + assert_eq!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(12)).len(), 1); + s.prefs.scripting.error_banner = false; + assert!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(13)).is_empty()); + assert!(d.entries.is_empty()); + } + + #[test] + fn replacement_project_with_reused_ids_does_not_reuse_errors() { + let (s, _, _) = fixture(true, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + wait_done(&d); + assert_eq!(d.update(&s, &ctx, cid, false, now + QUIET).len(), 1); + let (replacement, _, _) = fixture(false, false); + assert_eq!(s.revision, replacement.revision); + assert!(d.update(&replacement, &ctx, cid, false, now + QUIET).is_empty()); + } + + #[test] + fn scans_keep_disabled_syntax_errors_and_non_solo_runtime_errors() { + let (mut s, _, _) = fixture(true, false); + let cid = s.active_comp_id().unwrap(); + let selected = s.state.selected_layers[0]; + s.expr_check = Some(|_| Err("invalid syntax".into())); + s.execute("prop.setExpression", json!({"layer":selected.0,"path":"transform/rotation","expression":"bad("})).unwrap(); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + start(&mut d, &s, &ctx, now); + wait_done(&d); + let errors = d.update(&s, &ctx, cid, false, now + QUIET); + assert_eq!(errors.len(), 2); + assert!(errors.iter().any(|e| e.disabled && e.message == "invalid syntax")); + assert!(errors.iter().any(|e| !e.disabled && e.layer_name == "Hidden expression")); + } + + #[test] + fn viewer_edits_do_not_wait_for_hidden_diagnostics_with_or_without_warnings() { + use crate::{EffectcraftApp, bench::Driver}; + let (s, host, entered) = fixture(false, true); + let mut d = Driver::new(EffectcraftApp::new(s), egui::vec2(1680., 1020.)); + for _ in 0..3 { + d.step(); + } + let now = Instant::now() + Duration::from_secs(1); + start(&mut d.app.expression_diagnostics, &d.app.session, &d.ctx, now); + entered.recv_timeout(Duration::from_secs(3)).unwrap(); + let layer = d.app.session.state.selected_layers[0]; + d.app.session.state.snapping = false; + let handle = format!("viewer.handle.{}.0", layer.0); + let before = d.app.auto.find(&handle).unwrap().rect; + let comp_rect = d.app.auto.find("viewer.comp").unwrap().rect; + let from = egui::pos2(comp_rect[0] + comp_rect[2] * 0.4, comp_rect[1] + comp_rect[3] * 0.4); + let to = from + egui::vec2(40., 20.); + d.step_with(vec![ + egui::Event::PointerMoved(from), + egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + d.step_with(vec![egui::Event::PointerMoved(to)]); + d.step_with(vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + d.step(); + let after = d.app.auto.find(&handle).unwrap().rect; + assert!(after[0] > before[0] + 30., "selection bounds did not follow the drag: {before:?} -> {after:?}"); + for warnings in [true, false] { + d.app.session.prefs.scripting.error_banner = warnings; + for i in 0..4 { + d.app.session.set_time(Tick::from_seconds_f64(i as f64 / 30.)); + d.app.session.execute("layer.setTransform", json!({"layer":layer.0,"prop":"position","value":[170.+i as f64,90.,0.]})).unwrap(); + d.app.session.execute("layer.setSwitch", json!({"layers":[layer.0],"switch":"video","value":i%2==0})).unwrap(); + d.step(); + } + } + // The diagnostic expression is still blocked on the worker, but every UI frame above + // finished. This assertion also catches reintroducing a second, synchronous banner. + assert!(!*host.gate.0.lock().unwrap()); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + host.release(); + } +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index ccffeb9f..9a3398d6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -29,7 +29,7 @@ use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; -use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; +use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, Gpu}; use rayon::prelude::*; @@ -109,6 +109,38 @@ impl std::hash::Hash for FrameKey { } } +/// Where a playing preview is and what it plays next (see [`Frames::set_playhead`]): the RAM +/// preview renders the frames it reaches first before later ones, and keeps them over frames it +/// reaches later or not at all (#595). +#[derive(Clone, Copy, Debug)] +pub struct Playhead { + /// The frame series that plays ([`FrameKey::same_series`]; its `frame` is ignored). + pub series: FrameKey, + /// The frame on screen. + pub cur: i64, + /// The preview's range, skip step and looping (as in its `PreviewPlan`). + pub start: i64, + pub end: i64, + pub step: i64, + pub looping: bool, +} + +impl Playhead { + /// How many frames the preview shows before it reaches `k` (0: on screen); `None` for + /// frames it doesn't reach (other series, outside the range, behind it without looping). + pub fn ahead(&self, k: &FrameKey) -> Option { + if !k.same_series(&self.series) || k.frame < self.start || k.frame > self.end { + return None; + } + let step = self.step.max(1); + if k.frame >= self.cur { + return Some((k.frame - self.cur) / step); + } + // Looping: through the end of the range and around from its start. + self.looping.then(|| (self.end.saturating_sub(self.cur) / step).saturating_add(1).saturating_add((k.frame - self.start) / step)) + } +} + struct Cache { map: HashMap, order: VecDeque, @@ -118,6 +150,8 @@ struct Cache { budget: usize, /// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]). gpu_budget: usize, + /// The playing preview, if any: evictions spare the frames it shows next. + playhead: Option, } impl Cache { @@ -132,12 +166,26 @@ impl Cache { if gpu { self.gpu_bytes += sz; } - while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; - self.remove(&old); - } + self.evict_to_budget(); self.evict_gpu(); } + /// The frame to evict next: the least recently shown, but while a preview plays, the least + /// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU + /// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet) + /// to keep the ones just shown, so a preview longer than the cache played into a gap that + /// prefetch kept refilling while it evicted the next frames (#595). + fn victim(&self) -> Option { + let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) }; + let mut farthest: Option<(i64, usize)> = None; + for (i, k) in self.order.iter().enumerate() { + match p.ahead(k) { + None => return Some(i), + Some(d) if farthest.is_none_or(|(f, _)| d > f) => farthest = Some((d, i)), + Some(_) => {} + } + } + farthest.map(|(_, i)| i) + } /// Video memory: drop the oldest GPU frames past their own budget. fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { @@ -181,7 +229,7 @@ impl Cache { } fn evict_to_budget(&mut self) { while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; + let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break }; self.remove(&old); } } @@ -213,6 +261,13 @@ type Identity = (u64, Option>); /// Content keys of (project revision, comp), computed once per revision. type ContentKeys = Arc>>; +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct ContentKeyJobs { + running: bool, + pending: VecDeque<(Arc, u64, u64)>, +} + fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { return k; @@ -369,6 +424,8 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + #[cfg(not(target_arch = "wasm32"))] + content_key_jobs: Arc>, gpu_retired: Arc, /// [`comp_content`] identities by (revision, comp), with the project that keeps an identity /// found by [`Frames::identity`] in another project. @@ -388,7 +445,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -403,6 +460,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + content_key_jobs: Arc::default(), gpu_retired: Arc::default(), identities: Mutex::default(), bases: Mutex::default(), @@ -460,11 +519,27 @@ pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { /// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { - let [w, h] = img.size; let k = fit_factor(img.size, max_side, min_k); if k == 1 { return img; } + average_down(&img, k) +} + +/// A shared frame as texture data (see [`fit_texture_by`]): the frame itself when it needs no +/// averaging, without copying its pixels. The viewer puts every frame a preview shows on screen; +/// copying a 4K frame first held the UI thread for about a millisecond each time (#595). +pub fn fit_shared_texture_by(img: &Arc, max_side: usize, min_k: usize) -> egui::ImageData { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { + return egui::ImageData::Color(img.clone()); + } + average_down(img, k).into() +} + +/// `img` averaged down by `k` (rounded up at the edges). +fn average_down(img: &egui::ColorImage, k: usize) -> egui::ColorImage { + let [w, h] = img.size; let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() @@ -662,7 +737,8 @@ impl Frames { } /// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar. - /// `opts` are the viewer's render options (part of the disk key). + /// `opts` are the viewer's render options (part of the disk key). Native queries return + /// no blue frames until the content key is ready; hashing never blocks timeline painting. pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec { let remote = self.remote.as_ref().filter(|r| r.disk()); let contains = |k: u128| match (&src.disk, remote) { @@ -673,14 +749,64 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } + #[cfg(not(target_arch = "wasm32"))] + let content = { + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); + let Some(content) = known else { + self.request_content_key(src.project.clone(), base.revision, base.comp); + return vec![]; + }; + content + }; + #[cfg(target_arch = "wasm32")] + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (0..frames.clamp(0, 100_000)) .filter(|f| !ram.contains(f)) - .filter(|f| contains(frame_disk_key(&self.content_keys, &src.project, &FrameKey { frame: *f, ..*base }, oh))) + .filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))) .collect() } + #[cfg(not(target_arch = "wasm32"))] + fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { + let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // Coalesce rapid edits. One worker hashes at most one revision at a time; retained + // snapshots are bounded even with multiple timelines open. + jobs.pending.retain(|(_, _, c)| *c != comp); + if jobs.pending.len() >= 8 { + jobs.pending.pop_front(); + } + jobs.pending.push_back((project, revision, comp)); + if jobs.running { + return; + } + jobs.running = true; + drop(jobs); + let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); + let work = move || loop { + let next = { + let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let next = jobs.pending.pop_back(); + if next.is_none() { + jobs.running = false; + } + next + }; + let Some((project, revision, comp)) = next else { return }; + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { + log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); + } + if let Some(ctx) = &ctx { + ctx.request_repaint(); + } + }; + match &self.pool { + Some(pool) => pool.spawn(work), + None => rayon::spawn(work), + } + } + /// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest /// frames when it shrinks. pub fn set_budget(&self, bytes: usize) { @@ -712,6 +838,26 @@ impl Frames { } } + /// The playing preview's position (`None` when no preview plays): prefetch renders the + /// frames it reaches first before the others, evictions spare the frames it shows next, and + /// queued prefetch frames of its series it no longer reaches are dropped (#595). + pub fn set_playhead(&self, playhead: Option) { + if let Ok(mut c) = self.cache.lock() { + c.playhead = playhead; + } + let Ok(mut q) = self.queue.lock() else { return }; + q.playhead = playhead; + let Some(p) = playhead else { return }; + let Ok(mut inf) = self.inflight.lock() else { return }; + q.jobs.retain(|j| { + let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); + if gone { + inf.remove(&j.key); + } + !gone + }); + } + /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { self.request_with(src, key, comp, t, opts, false); @@ -756,13 +902,22 @@ impl Frames { for j in q.jobs.iter_mut() { j.urgent = j.key == key; } + // The comp's viewer frames of other times that are rendering are called off too: a + // movie seeking for each of them held the decoder the frame under the pointer + // waited for, so it came seconds after the pointer stopped (#329). Another revision + // of the same frame (a live edit) finishes, as the viewer shows it meanwhile. + for (k, cancel) in &q.urgent_cancels { + if k.comp == key.comp && k.frame != key.frame { + cancel.store(true, Ordering::Relaxed); + } + } } if !inf.insert(key) { return; } q.seq += 1; let seq = q.seq; - q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); + q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq, cancel: Arc::default() }); drop(inf); drop(q); #[cfg(not(target_arch = "wasm32"))] @@ -835,7 +990,7 @@ impl Frames { } let job = { let Ok(mut q) = self.queue.lock() else { break }; - let Some(i) = Queue::best(&q.jobs) else { break }; + let Some(i) = q.best() else { break }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -963,11 +1118,12 @@ impl Worker { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = Queue::best(&q.jobs) else { return false }; + let Some(i) = q.best() else { return false }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; + q.urgent_cancels.push((job.key, job.cancel.clone())); } job }; @@ -977,10 +1133,11 @@ impl Worker { let disk_hit = from_disk.is_some(); let ci = match from_disk { Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), - None => match self.render_guarded(&job) { + None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) { Some(ci) => ci, + // Called off (frames it read may be missing) or failed: not cached, and not + // "in progress" forever: the viewer asks for it again. None => { - // Not "in progress" forever: the viewer asks for it again. self.release(job.key, job.urgent); return true; } @@ -1025,6 +1182,9 @@ impl Worker { q.running.remove(&key); if urgent { q.urgent_running = q.urgent_running.saturating_sub(1); + if let Some(i) = q.urgent_cancels.iter().position(|(k, _)| *k == key) { + q.urgent_cancels.swap_remove(i); + } } } if let Some(ctx) = &self.ctx { @@ -1061,7 +1221,8 @@ impl Worker { return None; } let g = job.src.gpu.as_ref()?; - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + let footage = job.footage(); + let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); @@ -1077,7 +1238,8 @@ impl Worker { /// Render a frame; `gpu: false` keeps it off the GPU compositor. fn render_job(&self, job: &Job, gpu: bool) -> FrameImage { - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + let footage = job.footage(); + let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); let gpu_allowed = gpu && !self.gpu_retired.load(Ordering::Acquire); // A failed GPU attempt may have populated intermediate entries before its scope @@ -1149,6 +1311,15 @@ struct Job { opts: RenderOpts, urgent: bool, seq: u64, + /// Set when the viewer no longer waits for this frame (see [`Frames::request_urgent`]). + cancel: Arc, +} + +impl Job { + /// The footage this job's render reads, called off with the job. + fn footage(&self) -> CancellableFootage<'_> { + CancellableFootage { inner: self.src.footage.as_ref(), cancel: self.cancel.clone() } + } } #[derive(Default)] @@ -1156,13 +1327,28 @@ struct Queue { jobs: Vec, running: HashSet, urgent_running: usize, + /// The running viewer (urgent) frames and how to call them off. + urgent_cancels: Vec<(FrameKey, Arc)>, seq: u64, + /// The playing preview, if any: its prefetch renders nearest first. + playhead: Option, } impl Queue { - /// The job that matters most: urgent first (newest), then prefetch in request order. - fn best(jobs: &[Job]) -> Option { - jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i) + /// The job that matters most: urgent first (newest), then, while a preview plays, the + /// prefetch frame it reaches first, then prefetch in request order. In request order alone, + /// frames asked for again after an eviction (the ones right ahead of the playhead) waited + /// behind every frame further ahead (#595). + fn best(&self) -> Option { + let p = self.playhead.as_ref(); + self.jobs + .iter() + .enumerate() + .max_by_key(|(_, j)| { + let near = if j.urgent { None } else { p.and_then(|p| p.ahead(&j.key)) }; + (j.urgent, near.is_some(), near.map_or(0, |d| -d), if j.urgent { j.seq as i64 } else { -(j.seq as i64) }) + }) + .map(|(i, _)| i) } } @@ -1170,6 +1356,67 @@ impl Queue { mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { + use std::sync::mpsc; + use std::time::{Duration, Instant}; + struct Disk(u128); + impl RemoteFrames for Disk { + fn slots(&self) -> usize { + 1 + } + fn disk(&self) -> bool { + true + } + fn disk_contains(&self, key: u128) -> bool { + self.0 == key + } + fn start(&self, _: RemoteJob, done: RemoteDone) { + done(Err("test has no renderer".into())); + } + } + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", serde_json::json!({"width":32,"height":18,"duration":1})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let opts = RenderOpts::default(); + let stored = disk_cache::frame_key(disk_cache::comp_content_key(&s.project, cid), 2, 1000, opts_hash(&opts)); + let mut frames = Frames { pool: Some(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap()), ..Default::default() }; + frames.set_remote(Some(Arc::new(Disk(stored)))); + let (entered, started) = mpsc::channel(); + let (release, wait) = mpsc::channel(); + frames.pool.as_ref().unwrap().spawn(move || { + entered.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + }); + started.recv_timeout(Duration::from_secs(3)).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: s.footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let mut base = key(1, 0, 0); + base.comp = cid.0; + for revision in 1..50 { + base.revision = revision; + assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty()); + } + assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked"); + assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); + release.send(()).unwrap(); + let until = Instant::now() + Duration::from_secs(3); + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { + assert!(Instant::now() < until); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready"); + assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed"); + } + /// Frames larger than the texture limit are averaged down by a whole factor (#201: an /// 11000×2200 comp at Full resolution against an 8192 limit). #[test] @@ -1341,6 +1588,94 @@ mod tests { assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread"); } + /// #329: scrubbing a long movie, the viewer frame of every step the pointer passed started + /// rendering and seeked the movie's decoder in turn; the frame under the pointer waited for + /// all of them. A newer viewer frame of another time calls off the one rendering, which is + /// not cached (its footage may be missing). + #[test] + fn a_newer_viewer_frame_calls_off_the_one_rendering() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::render::Image; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + use std::cell::RefCell; + thread_local! { + static CANCEL: RefCell>> = const { RefCell::new(None) }; + } + /// A movie whose first frames take a long seek, which a called-off read gives up. + #[derive(Default)] + struct Seeking { + seeking: AtomicBool, + called_off: AtomicBool, + } + impl FootageSource for Seeking { + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + let prev = CANCEL.replace(Some(cancel.clone())); + f(); + CANCEL.set(prev); + } + fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + if t < Tick::from_seconds_f64(0.25) { + self.seeking.store(true, Ordering::SeqCst); + for _ in 0..1000 { + if CANCEL.with_borrow(|c| c.as_ref().is_some_and(|c| c.load(Ordering::SeqCst))) { + self.called_off.store(true, Ordering::SeqCst); + return None; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + Some(Arc::new(Image::new(4, 4))) + } + } + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(Seeking::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let content = f.content_of(&s.project, s.revision, cid); + let key = |frame| FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 }; + f.request_urgent(&src, key(0), cid, Tick::ZERO, RenderOpts::default()); + for _ in 0..1000 { + if footage.seeking.load(Ordering::SeqCst) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(footage.seeking.load(Ordering::SeqCst), "the first frame's render reached the footage"); + f.request_urgent(&src, key(150), cid, FrameRate::FPS_30.tick_of(150), RenderOpts::default()); + for _ in 0..1000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(footage.called_off.load(Ordering::SeqCst), "the superseded frame's seek ran on"); + assert!(f.is_cached(&key(150)), "the newest frame rendered"); + assert!(!f.is_cached(&key(0)), "a called-off frame is not cached"); + assert_eq!(f.inflight(), 0, "released"); + } + /// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer /// frames still waiting (they would all render first), but not prefetch frames or another /// comp's viewer frame. @@ -1406,6 +1741,80 @@ mod tests { assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0))); } + fn playhead(cur: i64, looping: bool) -> Playhead { + Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping } + } + + /// #595: a playing preview's cache is full. The frames just ahead of the playhead were + /// rendered before the ones just shown, so plain LRU evicted them first and the preview + /// played into a gap. Frames the preview no longer reaches go first, then the ones it + /// reaches last. + #[test] + fn a_full_cache_keeps_the_frames_just_ahead_of_the_playhead() { + let f = Frames::default(); + f.set_budget(4 * 16); + // Prefetch rendered 11..=13, then 10 was shown (the most recently used). + for i in [11, 12, 13, 10] { + insert(&f, key(1, i, 0)); + } + let cached = |f: &Frames| f.cached_frames(&key(1, 0, 0)); + f.set_playhead(Some(playhead(10, false))); + // The next frame rendered is the one the preview reaches last: the frames just ahead stay. + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 11, 12, 13]); + // Without looping, a frame behind the playhead goes before any frame ahead of it. + f.set_playhead(Some(playhead(12, false))); + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 12, 13, 14]); + // Looping, the frame just behind is the one reached last. + f.set_playhead(Some(playhead(13, true))); + insert(&f, key(1, 0, 0)); + assert_eq!(cached(&f), [0, 10, 13, 14]); + // Stopped: least recently shown first again. + f.set_playhead(None); + insert(&f, key(1, 50, 0)); + assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0))); + } + + /// #595: prefetch renders the frames the preview reaches first, whatever order they were + /// asked for in (a frame evicted and asked for again waited behind every frame further + /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. + #[test] + fn prefetch_renders_the_frames_the_playhead_reaches_first() { + struct Idle; + impl RemoteFrames for Idle { + fn slots(&self) -> usize { + 0 + } + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + let src = RenderSource { + project: Arc::default(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + for frame in [40, 30, 20, 5, 12, 11] { + f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default()); + } + f.request(&src, FrameKey { comp: 2, ..key(1, 1, 0) }, ItemId(2), Tick::ZERO, RenderOpts::default()); + f.set_playhead(Some(playhead(10, false))); + let mut order = vec![]; + loop { + let mut q = f.queue.lock().unwrap(); + let Some(i) = q.best() else { break }; + let j = q.jobs.swap_remove(i); + order.push((j.key.comp, j.key.frame)); + } + assert_eq!(order, [(1, 11), (1, 12), (1, 20), (1, 30), (1, 40), (2, 1)]); + assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index ca4cea15..fb381cfd 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,7 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; -use effectcraft_engine::project::{LayerId, LayerSource}; +use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -101,7 +101,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None }; let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; - egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| { + egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { for tool in slot.iter() { let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); @@ -216,9 +216,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let tip = tr_args("Appearance Mode: {}", &[&mode]); let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0)); - if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() { + let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip); + if response.clicked() { app.cycle_appearance(ui.ctx()); } + response.context_menu(|ui| { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } + }); app.auto.add("header.appearance", r, &tip); rx = r.min.x - 4.0; rx -= 10.0; @@ -229,7 +255,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mresp = ui.interact(more, egui::Id::new("ws-more"), Sense::click()); p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim }); app.auto.add("header.workspaces", more, "Workspaces"); - mresp.context_menu(|ui| ws_menu(app, ui)); + crate::widgets::context_menu(&mresp, |ui| ws_menu(app, ui)); if mresp.clicked() { widgets::open_popup(ui, egui::Id::new("ws-pop")); } @@ -303,6 +329,24 @@ fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) { } } +/// The Colors property of the first Gradient Fill (`gfill`) or Gradient Stroke (`gstroke`) on the +/// shape `layers`, in contents order through nested groups: (layer, property uid). The toolbar's +/// gradient swatch edits it in the Gradient Editor. +fn gradient_colors(app: &EffectcraftApp, layers: &[u64], paint: &str) -> Option<(u64, u64)> { + fn find(g: &PropGroup, paint: &str, depth: usize) -> Option { + if depth >= 16 { + return None; + } + g.groups().filter(|c| c.enabled).find_map(|c| match c.match_id.as_str() { + m if m == paint => c.get("colors").map(|p| p.uid), + "group" | "contents" => find(c, paint, depth + 1), + _ => None, + }) + } + let comp = app.session.active_comp()?; + layers.iter().find_map(|&l| comp.layer(LayerId(l)).and_then(|layer| layer.props.sub("contents")).and_then(|c| find(c, paint, 0)).map(|u| (l, u))) +} + /// The colour picker of the Fill or Stroke swatch. fn picker_id(key: &str) -> egui::Id { egui::Id::new(("tool-pick", key)) @@ -355,8 +399,9 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa if wresp.clicked() { widgets::open_popup(ui, opts); } - // The swatch picks a Solid Color's colour and opens the options for the other kinds; - // Alt-click steps through the kinds (as in After Effects). + // The swatch picks a Solid Color's colour, edits a Linear or Radial Gradient's colours in + // the Gradient Editor (the selected shape layer's Gradient Fill / Gradient Stroke), and + // opens the options otherwise; Alt-click steps through the kinds (as in After Effects). let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0)); let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label())); paint_swatch(p, sr, &tp, sresp.hovered(), &t); @@ -368,7 +413,16 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa paint_kind(app, shape_layers, key, next); } else if tp.kind == PaintKind::Solid { widgets::open_popup(ui, picker_id(key)); + } else if let Some((layer, prop)) = matches!(tp.kind, PaintKind::Linear | PaintKind::Radial) + .then(|| gradient_colors(app, shape_layers, if key == "fill" { "gfill" } else { "gstroke" })) + .flatten() + { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "prop.editGradient", json!({"layer": layer, "prop": prop})) { + app.ui.status = e; + } } else { + // No selected shape layer with that gradient: its options (new shapes' kind). widgets::open_popup(ui, opts); } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b04cb29..4e814ea2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -11,7 +11,7 @@ use effectcraft_engine::menus::MenuEntry; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 6] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br"]; +const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -57,6 +57,11 @@ pub(crate) fn brazilian_portuguese(app: &EffectcraftApp) -> bool { language(app) == "pt-br" } +/// The UI is in Czech. +pub(crate) fn czech(app: &EffectcraftApp) -> bool { + language(app) == "cs" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build stays in English because it has no system-locale probe. @@ -72,7 +77,7 @@ fn system_language() -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -82,6 +87,7 @@ fn supported(locale: Option<&str>) -> &'static str { "ja" => "ja", "uk" => "uk", "es" => "es", + "cs" => "cs", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -103,6 +109,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "cs" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Zobrazit v Průzkumníku", + "Reveal in File Manager" => return "Zobrazit ve správci souborů", + _ => {} + } + } match language { "ja" => lookup(JAPANESE, command, source), "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), @@ -110,6 +123,10 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> "uk" => lookup(UKRAINIAN, command, source), "es" => lookup(SPANISH, command, source), "pt-br" => lookup(PORTUGUESE, command, source), + "ru" => lookup(RUSSIAN, command, source), + "fr" => lookup(FRENCH, command, source), + "de" => lookup(GERMAN, command, source), + "cs" => lookup(CZECH, command, source), _ => source, } } @@ -130,6 +147,7 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "uk" => ("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення"), "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), + "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), _ => return shown, }; match e.command.as_str() { @@ -152,6 +170,17 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "cs" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("Přiřadit zkratku pracovnímu prostoru „{name}“"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("Přiřadit zkratku 3D zobrazení „{name}“"); + } + } "uk" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -4001,6 +4030,2525 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Abrir projeto de demonstração"), ]; +// >>> Russian, French and German menu catalogs + +/// Russian (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const RUSSIAN: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Копировать кадр в буфер обмена"), + ("", "Front", "Передняя грань"), + ("", "Bevel", "Фаска"), + ("", "Side", "Боковая грань"), + ("", "Back", "Задняя грань"), + ("layer.addTextAnimator", "Color", "Цвет"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "О EffectCraft..."), + ("", "Settings...", "Настройки..."), + ("app.settings", "General...", "Общие..."), + ("app.settings", "Startup & Repair...", "Запуск и восстановление..."), + ("app.settings", "Project...", "Проект..."), + ("app.settings", "Composition...", "Композиция..."), + ("app.settings", "Previews...", "Предварительный просмотр..."), + ("app.settings", "Appearance...", "Внешний вид..."), + ("app.settings", "Grids & Guides...", "Сетки и направляющие..."), + ("app.settings", "Labels...", "Метки..."), + ("app.settings", "Type...", "Текст..."), + ("app.settings", "Import...", "Импорт..."), + ("app.settings", "Export...", "Экспорт..."), + ("app.settings", "Audio...", "Звук..."), + ("app.settings", "Disk...", "Диск..."), + ("app.settings", "Memory & CPU...", "Память и CPU..."), + ("app.settings", "Video...", "Видео..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Скрипты и выражения..."), + ("app.hide", "Hide EffectCraft", "Скрыть EffectCraft"), + ("app.hideOthers", "Hide Others", "Скрыть остальные"), + ("app.showAll", "Show All", "Показать все"), + ("app.quit", "Quit EffectCraft", "Выйти из EffectCraft"), + ("", "File", "Файл"), + ("", "New", "Создать"), + ("file.newProject", "New Project", "Новый проект"), + ("file.newFromTemplate", "New Project from Template...", "Новый проект из шаблона..."), + ("project.newFolder", "New Folder", "Новая папка"), + ("file.open", "Open Project...", "Открыть проект..."), + ("", "Open Recent", "Открыть недавние"), + ("file.clearRecent", "Clear Recent Projects", "Очистить список недавних проектов"), + ("file.close", "Close", "Закрыть"), + ("file.closeProject", "Close Project", "Закрыть проект"), + ("file.save", "Save", "Сохранить"), + ("", "Save As", "Сохранить как"), + ("file.saveAs", "Save As...", "Сохранить как..."), + ("file.saveCopy", "Save a Copy...", "Сохранить копию..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Сохранить копию как XML..."), + ("templates.saveAs", "Save as Template...", "Сохранить как шаблон..."), + ("file.incrementAndSave", "Increment and Save", "Увеличить номер версии и сохранить"), + ("file.revert", "Revert", "Вернуть к сохраненному"), + ("", "Import", "Импорт"), + ("file.import", "File...", "Файл..."), + ("file.importMultiple", "Multiple Files...", "Несколько файлов..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Заполнитель..."), + ("file.importSolid", "Solid...", "Сплошной цвет..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Точка схода (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Шаблон основной графики..."), + ("", "Import Recent Footage", "Импортировать недавние материалы"), + ("file.clearRecentFootage", "Clear Recent Footage", "Очистить список недавних материалов"), + ("", "Export", "Экспорт"), + ("renderQueue.add", "Add to Render Queue", "Добавить в очередь визуализации"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Шаблон основной графики..."), + ("app.find", "Find", "Найти"), + ("layer.addItem", "Add Footage to Comp", "Добавить материал в композицию"), + ("file.newCompFromSelection", "New Comp from Selection...", "Новая композиция из выделенного..."), + ("", "Dependencies", "Зависимости"), + ("file.collectFiles", "Collect Files...", "Собрать файлы..."), + ("file.consolidateFootage", "Consolidate All Footage", "Объединить повторяющиеся материалы"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Удалить неиспользуемые материалы"), + ("file.reduceProject", "Reduce Project", "Сократить проект"), + ("file.findMissing", "Find Missing Effects", "Найти отсутствующие эффекты"), + ("file.findMissing", "Find Missing Fonts", "Найти отсутствующие шрифты"), + ("file.findMissing", "Find Missing Footage", "Найти отсутствующие материалы"), + ("file.relinkFootage", "Relink Missing Footage...", "Пересвязать отсутствующие материалы..."), + ("file.watchFolder", "Watch Folder...", "Папка наблюдения..."), + ("", "Scripts", "Скрипты"), + ("file.installScript", "Install Script File...", "Установить файл скрипта..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."), + ("file.runScript", "Run Script File...", "Выполнить файл скрипта..."), + ("", "Create Proxy", "Создать суррогат"), + ("file.createProxy", "Still...", "Стоп-кадр..."), + ("file.createProxy", "Movie...", "Видео..."), + ("", "Set Proxy", "Назначить суррогат"), + ("file.setProxy", "File...", "Файл..."), + ("file.setProxyNone", "None", "Нет"), + ("", "Interpret Footage", "Интерпретировать материал"), + ("file.interpretFootage", "Main...", "Основные..."), + ("file.interpretProxy", "Proxy...", "Суррогат..."), + ("file.rememberInterpretation", "Remember Interpretation", "Запомнить интерпретацию"), + ("file.applyInterpretation", "Apply Interpretation", "Применить интерпретацию"), + ("", "Replace Footage", "Заменить материал"), + ("file.replaceFootage", "File...", "Файл..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Заполнитель..."), + ("file.replaceWithSolid", "Solid...", "Сплошной цвет..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Многослойной композицией"), + ("file.reloadFootage", "Reload Footage", "Перезагрузить материал"), + ("file.revealInFinder", "Reveal in Finder", "Показать в Finder"), + ("file.projectSettings", "Project Settings...", "Параметры проекта..."), + ("app.quit", "Exit", "Выход"), + ("", "Edit", "Правка"), + ("edit.undo", "Undo", "Отменить"), + ("edit.redo", "Redo", "Повторить"), + ("", "History", "История"), + ("app.commandPalette", "Quick Apply...", "Быстрое применение..."), + ("edit.cut", "Cut", "Вырезать"), + ("edit.copy", "Copy", "Копировать"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Копировать со связями свойств"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Копировать с относительными связями свойств"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Копировать только выражение"), + ("edit.paste", "Paste", "Вставить"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Вставить обращенные ключевые кадры"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Вставить текст с совпадающим форматированием"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Вставить только форматирование текста"), + ("edit.clear", "Clear", "Очистить"), + ("edit.duplicate", "Duplicate", "Дублировать"), + ("edit.splitLayer", "Split Layer", "Разделить слой"), + ("edit.liftWorkArea", "Lift Work Area", "Извлечь рабочую область"), + ("edit.extractWorkArea", "Extract Work Area", "Вырезать рабочую область"), + ("edit.selectAll", "Select All", "Выделить все"), + ("edit.deselectAll", "Deselect All", "Снять выделение со всего"), + ("", "Label", "Метка"), + ("edit.selectLabelGroup", "Select Label Group", "Выделить группу метки"), + ("edit.label", "None", "Нет"), + ("edit.label", "Red", "Красный"), + ("edit.label", "Yellow", "Желтый"), + ("edit.label", "Aqua", "Аква"), + ("edit.label", "Pink", "Розовый"), + ("edit.label", "Lavender", "Лавандовый"), + ("edit.label", "Peach", "Персиковый"), + ("edit.label", "Sea Foam", "Морская пена"), + ("edit.label", "Blue", "Синий"), + ("edit.label", "Green", "Зеленый"), + ("edit.label", "Purple", "Фиолетовый"), + ("edit.label", "Orange", "Оранжевый"), + ("edit.label", "Brown", "Коричневый"), + ("edit.label", "Fuchsia", "Фуксия"), + ("edit.label", "Cyan", "Голубой"), + ("edit.label", "Sandstone", "Песчаный"), + ("edit.label", "Dark Green", "Темно-зеленый"), + ("app.settings", "Edit Label Colors...", "Изменить цвета меток..."), + ("", "Select Keyframe Label Group", "Выделить группу метки ключевых кадров"), + ("keys.selectLabelGroup", "On Selected Layers", "На выделенных слоях"), + ("keys.selectLabelGroup", "On All Layers", "На всех слоях"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Видимые ключевые кадры на выделенных слоях"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Видимые ключевые кадры на всех слоях"), + ("", "Purge", "Очистить память"), + ("edit.purge", "All Cache...", "Весь кеш..."), + ("edit.purge", "All Memory & Disk Cache...", "Всю память и дисковый кеш..."), + ("edit.purge", "All Memory", "Всю память"), + ("edit.purge", "All Disk Cache...", "Весь дисковый кеш..."), + ("edit.purge", "All 3D Cache...", "Весь 3D-кеш..."), + ("edit.purgeUndo", "Undo", "Историю отмен"), + ("edit.purge", "Image Cache Memory", "Кеш изображений в памяти"), + ("edit.purge", "Snapshot", "Снимок"), + ("edit.editOriginal", "Edit Original...", "Редактировать оригинал..."), + ("", "Templates", "Шаблоны"), + ("app.templates", "Render Settings...", "Параметры визуализации..."), + ("app.templates", "Output Module...", "Модуль вывода..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Сочетания клавиш"), + ("", "Preferences", "Настройки"), + ("", "Composition", "Композиция"), + ("comp.new", "New Composition...", "Новая композиция..."), + ("comp.settings", "Composition Settings...", "Параметры композиции..."), + ("comp.setPosterTime", "Set Poster Time", "Задать время кадра-афиши"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Обрезать композицию по рабочей области"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Обрезать композицию по области интереса"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Обрезать композицию по границам выделенных слоев"), + ("render.addOutputModule", "Add Output Module", "Добавить модуль вывода"), + ("", "Preview", "Предварительный просмотр"), + ("playback.toggle", "Play Current Preview", "Воспроизвести текущий предпросмотр"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Кэшировать кадры в простое"), + ("playback.audio", "Audio", "Звук"), + ("", "Save Frame As", "Сохранить кадр как"), + ("comp.saveFrameAs", "File...", "Файл..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Слои Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Предварительный рендеринг..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Сохранить текущий предпросмотр..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Открыть в основной графике"), + ("", "Responsive Design — Time", "Адаптивный дизайн — время"), + ("comp.responsiveTime", "Create Intro", "Создать вступление"), + ("comp.responsiveTime", "Create Outro", "Создать завершение"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Создать защищенный участок из рабочей области"), + ("comp.flowchart", "Composition Flowchart", "Блок-схема композиции"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Мини-блок-схема композиции"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Создать среду VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Извлечь кубическую карту..."), + ("", "Layer", "Слой"), + ("layer.newText", "Text", "Текст"), + ("layer.newSolid", "Solid...", "Сплошной цвет..."), + ("layer.newLight", "Light...", "Свет..."), + ("layer.newCamera", "Camera...", "Камера..."), + ("layer.newNull", "Null Object", "Нулевой объект"), + ("layer.newShape", "Shape Layer", "Слой-фигура"), + ("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."), + ("layer.new3dPrimitive", "Cube", "Куб"), + ("layer.new3dPrimitive", "Sphere", "Сфера"), + ("layer.new3dPrimitive", "Plane", "Плоскость"), + ("layer.new3dPrimitive", "Torus", "Тор"), + ("layer.new3dPrimitive", "Cone", "Конус"), + ("layer.new3dPrimitive", "Cylinder", "Цилиндр"), + ("layer.settings", "Layer Settings...", "Параметры слоя..."), + ("layer.openLayer", "Open Layer", "Открыть слой"), + ("layer.openSource", "Open Layer Source", "Открыть источник слоя"), + ("layer.revealInFinder", "Reveal in Finder", "Показать в Finder"), + ("", "Mask", "Маска"), + ("layer.addMask", "New Mask", "Новая маска"), + ("layer.mask.shape", "Mask Shape...", "Форма маски..."), + ("layer.mask.set", "Mask Feather...", "Растушевка маски..."), + ("layer.mask.set", "Mask Opacity...", "Непрозрачность маски..."), + ("layer.mask.set", "Mask Expansion...", "Расширение маски..."), + ("layer.mask.reset", "Reset Mask", "Сбросить маску"), + ("layer.mask.remove", "Remove Mask", "Удалить маску"), + ("layer.mask.removeAll", "Remove All Masks", "Удалить все маски"), + ("track.mask", "Track Mask", "Отслеживать маску"), + ("", "Mode", "Режим"), + ("layer.mask.mode", "None", "Нет"), + ("layer.mask.mode", "Add", "Добавление"), + ("layer.mask.mode", "Subtract", "Вычитание"), + ("layer.mask.mode", "Intersect", "Пересечение"), + ("layer.mask.mode", "Lighten", "Осветление"), + ("layer.mask.mode", "Darken", "Затемнение"), + ("layer.mask.mode", "Difference", "Разница"), + ("layer.mask.invert", "Inverted", "Инвертированная"), + ("layer.mask.lock", "Locked", "Заблокирована"), + ("", "Motion Blur", "Размытие в движении"), + ("layer.mask.motionBlur", "Same As Layer", "Как у слоя"), + ("layer.mask.motionBlur", "On", "Включено"), + ("layer.mask.motionBlur", "Off", "Выключено"), + ("", "Feather Falloff", "Спад растушевки"), + ("layer.mask.featherFalloff", "Smooth", "Плавный"), + ("layer.mask.featherFalloff", "Linear", "Линейный"), + ("layer.mask.unlockAll", "Unlock All Masks", "Разблокировать все маски"), + ("layer.mask.lockOthers", "Lock Other Masks", "Заблокировать остальные маски"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Скрыть заблокированные маски"), + ("", "Mask and Shape Path", "Контур маски и фигуры"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Замкнутый"), + ("path.convertToBezier", "Convert To Bezier Path", "Преобразовать в кривую Безье"), + ("path.setFirstVertex", "Set First Vertex", "Задать первую вершину"), + ("path.freeTransform", "Free Transform Points", "Свободное трансформирование точек"), + ("", "Quality", "Качество"), + ("layer.quality", "Best", "Наилучшее"), + ("layer.quality", "Draft", "Черновое"), + ("layer.quality", "Wireframe", "Каркас"), + ("layer.sampling", "Bilinear", "Билинейная"), + ("layer.sampling", "Bicubic", "Бикубическая"), + ("", "Switches", "Переключатели"), + ("layer.hideOtherVideo", "Hide Other Video", "Скрыть остальное видео"), + ("layer.showAllVideo", "Show All Video", "Показать все видео"), + ("layer.unlockAll", "Unlock All Layers", "Разблокировать все слои"), + ("layer.expressions", "Enable Expressions", "Включить выражения"), + ("layer.expressions", "Disable Expressions", "Отключить выражения"), + ("layer.setSwitch", "Shy", "Застенчивый"), + ("layer.setSwitch", "Lock", "Заблокировать"), + ("layer.setSwitch", "Audio", "Звук"), + ("layer.setSwitch", "Video", "Видео"), + ("layer.setSwitch", "Solo", "Соло"), + ("layer.setSwitch", "Effect", "Эффект"), + ("layer.setSwitch", "Collapse", "Свернуть трансформации"), + ("layer.setSwitch", "Motion Blur", "Размытие в движении"), + ("layer.setSwitch", "Adjustment Layer", "Корректирующий слой"), + ("", "Transform", "Трансформирование"), + ("layer.transform", "Reset", "Сбросить"), + ("layer.setTransform", "Anchor Point...", "Опорная точка..."), + ("layer.setTransform", "Position...", "Положение..."), + ("layer.setTransform", "Scale...", "Масштаб..."), + ("layer.setTransform", "Orientation...", "Ориентация..."), + ("layer.setTransform", "Rotation...", "Вращение..."), + ("layer.setTransform", "Opacity...", "Непрозрачность..."), + ("layer.transform", "Flip Horizontal", "Отразить по горизонтали"), + ("layer.transform", "Flip Vertical", "Отразить по вертикали"), + ("layer.transform", "Center In View", "Центрировать в окне просмотра"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Центрировать опорную точку в содержимом слоя"), + ("layer.transform", "Fit to Comp", "По размеру композиции"), + ("layer.transform", "Fit to Comp Width", "По ширине композиции"), + ("layer.transform", "Fit to Comp Height", "По высоте композиции"), + ("layer.autoOrient", "Auto-Orient...", "Автоориентация..."), + ("", "Time", "Время"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Включить переназначение времени"), + ("layer.timeReverse", "Time-Reverse Layer", "Обратить время слоя"), + ("layer.timeStretch", "Time Stretch...", "Растяжение времени..."), + ("layer.freezeFrame", "Freeze Frame", "Стоп-кадр"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Заморозить на последнем кадре"), + ("layer.alignVideoToData", "Align Video to Data", "Выровнять видео по данным"), + ("", "Frame Blending", "Смешивание кадров"), + ("layer.frameBlending", "Off", "Выключено"), + ("layer.frameBlending", "Frame Mix", "Смешивание кадров"), + ("layer.frameBlending", "Pixel Motion", "Движение пикселей"), + ("layer.setSwitch", "3D Layer", "3D-слой"), + ("layer.setSwitch", "Guide Layer", "Направляющий слой"), + ("layer.environment", "Environment Layer", "Слой среды"), + ("", "Markers", "Маркеры"), + ("layer.addMarker", "Add Marker", "Добавить маркер"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Обновить маркеры из источника"), + ("layer.markersLock", "Lock Markers", "Заблокировать маркеры"), + ("layer.deleteAllMarkers", "Delete All Markers", "Удалить все маркеры"), + ("layer.setSwitch", "Preserve Transparency", "Сохранять прозрачность"), + ("", "Blending Mode", "Режим наложения"), + ("layer.setBlendMode", "Normal", "Обычный"), + ("layer.setBlendMode", "Dissolve", "Растворение"), + ("layer.setBlendMode", "Dancing Dissolve", "Танцующее растворение"), + ("layer.setBlendMode", "Darken", "Затемнение"), + ("layer.setBlendMode", "Multiply", "Умножение"), + ("layer.setBlendMode", "Color Burn", "Затемнение основы"), + ("layer.setBlendMode", "Classic Color Burn", "Классическое затемнение основы"), + ("layer.setBlendMode", "Linear Burn", "Линейное затемнение"), + ("layer.setBlendMode", "Darker Color", "Темнее"), + ("layer.setBlendMode", "Add", "Добавление"), + ("layer.setBlendMode", "Lighten", "Осветление"), + ("layer.setBlendMode", "Screen", "Экран"), + ("layer.setBlendMode", "Color Dodge", "Осветление основы"), + ("layer.setBlendMode", "Classic Color Dodge", "Классическое осветление основы"), + ("layer.setBlendMode", "Linear Dodge", "Линейное осветление"), + ("layer.setBlendMode", "Lighter Color", "Светлее"), + ("layer.setBlendMode", "Overlay", "Перекрытие"), + ("layer.setBlendMode", "Soft Light", "Мягкий свет"), + ("layer.setBlendMode", "Hard Light", "Жесткий свет"), + ("layer.setBlendMode", "Linear Light", "Линейный свет"), + ("layer.setBlendMode", "Vivid Light", "Яркий свет"), + ("layer.setBlendMode", "Pin Light", "Точечный свет"), + ("layer.setBlendMode", "Hard Mix", "Жесткое смешение"), + ("layer.setBlendMode", "Difference", "Разница"), + ("layer.setBlendMode", "Classic Difference", "Классическая разница"), + ("layer.setBlendMode", "Exclusion", "Исключение"), + ("layer.setBlendMode", "Subtract", "Вычитание"), + ("layer.setBlendMode", "Divide", "Деление"), + ("layer.setBlendMode", "Hue", "Цветовой тон"), + ("layer.setBlendMode", "Saturation", "Насыщенность"), + ("layer.setBlendMode", "Color", "Цветность"), + ("layer.setBlendMode", "Luminosity", "Яркость"), + ("layer.setBlendMode", "Stencil Alpha", "Трафарет альфа-канала"), + ("layer.setBlendMode", "Stencil Luma", "Трафарет яркости"), + ("layer.setBlendMode", "Silhouette Alpha", "Силуэт альфа-канала"), + ("layer.setBlendMode", "Silhouette Luma", "Силуэт яркости"), + ("layer.setBlendMode", "Alpha Add", "Сложение альфа-канала"), + ("layer.setBlendMode", "Luminescent Premul", "Люминесцентное предумножение"), + ("layer.setBlendMode", "Next Blending Mode", "Следующий режим наложения"), + ("layer.setBlendMode", "Previous Blending Mode", "Предыдущий режим наложения"), + ("", "Track Matte", "Трек-маска"), + ("layer.trackMatte", "No Track Matte", "Без трек-маски"), + ("layer.trackMatte", "Alpha Matte", "Альфа-маска"), + ("layer.trackMatte", "Alpha Inverted Matte", "Инвертированная альфа-маска"), + ("layer.trackMatte", "Luma Matte", "Маска яркости"), + ("layer.trackMatte", "Luma Inverted Matte", "Инвертированная маска яркости"), + ("layer.trackMatte", "Matte with Layer Above", "Маска от слоя выше"), + ("layer.trackMatte", "Matte with Layer Below", "Маска от слоя ниже"), + ("", "Layer Styles", "Стили слоев"), + ("layer.style.options", "Layer Style Options...", "Параметры стилей слоев..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Преобразовать в редактируемые стили"), + ("layer.style.showAll", "Show All", "Показать все"), + ("layer.style.removeAll", "Remove All", "Удалить все"), + ("layer.style.dropShadow", "Drop Shadow", "Тень"), + ("layer.style.innerShadow", "Inner Shadow", "Внутренняя тень"), + ("layer.style.outerGlow", "Outer Glow", "Внешнее свечение"), + ("layer.style.innerGlow", "Inner Glow", "Внутреннее свечение"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Фаска и тиснение"), + ("layer.style.satin", "Satin", "Атлас"), + ("layer.style.colorOverlay", "Color Overlay", "Наложение цвета"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Наложение градиента"), + ("layer.style.stroke", "Stroke", "Обводка"), + ("path.groupShapes", "Group Shapes", "Сгруппировать фигуры"), + ("path.ungroupShapes", "Ungroup Shapes", "Разгруппировать фигуры"), + ("", "Arrange", "Упорядочить"), + ("layer.arrange", "Bring Layer to Front", "Переместить слой на передний план"), + ("layer.arrange", "Bring Layer Forward", "Переместить слой вперед"), + ("layer.arrange", "Send Layer Backward", "Переместить слой назад"), + ("layer.arrange", "Send Layer to Back", "Переместить слой на задний план"), + ("", "Reveal", "Показать"), + ("layer.revealSource", "Reveal Layer Source in Project", "Показать источник слоя в проекте"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Показать слой в блок-схеме проекта"), + ("comp.revealInProject", "Reveal Composition in Project", "Показать композицию в проекте"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Показать ошибки выражений"), + ("", "Create", "Создать"), + ("layer.create", "Convert to Editable Text", "Преобразовать в редактируемый текст"), + ("layer.create", "Create Shapes from Text", "Создать фигуры из текста"), + ("layer.create", "Create Masks from Text", "Создать маски из текста"), + ("layer.create", "Create Shapes from Vector Layer", "Создать фигуры из векторного слоя"), + ("layer.create", "Create Keyframes from Data", "Создать ключевые кадры из данных"), + ("layer.create", "Null Controllers for Positional Points", "Нулевые контроллеры для точек положения"), + ("layer.create", "Null Controllers for Path Points", "Нулевые контроллеры для точек контура"), + ("layer.create", "Nulls Following Path Points", "Нулевые объекты, следующие за точками контура"), + ("layer.create", "Null Tracing Along Path", "Нулевой объект, движущийся вдоль контура"), + ("layer.create", "Create 3D Layer Instance", "Создать экземпляр 3D-слоя"), + ("", "Camera", "Камера"), + ("camera.fromView", "Create Camera from 3D View", "Создать камеру из 3D-вида"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Создать стерео-риг 3D"), + ("camera.orbitNull", "Create Orbit Null", "Создать орбитальный нулевой объект"), + ("camera.fromModel", "Create Cameras from 3D Model", "Создать камеры из 3D-модели"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Связать фокусное расстояние с точкой интереса"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Связать фокусное расстояние со слоем"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Задать фокусное расстояние по слою"), + ("layer.cameraSettings", "Camera Settings...", "Параметры камеры..."), + ("view.reset3DView", "Reset 3D View", "Сбросить 3D-вид"), + ("", "Light", "Свет"), + ("light.fromModel", "Create Lights from 3D Model", "Создать источники света из 3D-модели"), + ("light.controlWithCamera", "Control Light with Camera", "Управлять светом с помощью камеры"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Создать фоновый слой освещения среды"), + ("", "Material", "Материал"), + ("material.revealSource", "Reveal Material Source in Project", "Показать источник материала в проекте"), + ("material.reset", "Reset Material", "Сбросить материал"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Дублировать и назначить материал"), + ("layer.autoTrace", "Auto-trace...", "Автотрассировка..."), + ("layer.precompose", "Pre-compose...", "Преокомпоновать..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Определение монтажных склеек..."), + ("", "Effect", "Эффект"), + ("window.panel", "Effect Controls", "Управление эффектами"), + ("effect.applyLast", "Last Effect", "Последний эффект"), + ("effect.removeAll", "Remove All", "Удалить все"), + ("effect.manage", "Manage Effects...", "Управление эффектами..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Загрузить плагин эффектов..."), + ("", "Animation", "Анимация"), + ("anim.savePreset", "Save Animation Preset...", "Сохранить заготовку анимации..."), + ("anim.applyPreset", "Apply Animation Preset...", "Применить заготовку анимации..."), + ("", "Recent Animation Presets", "Недавние заготовки анимации"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Очистить список недавних заготовок"), + ("anim.browsePresets", "Browse Presets...", "Просмотреть заготовки..."), + ("", "Text Animation Presets", "Текстовые заготовки анимации"), + ("layer.applyTextPreset", "Typewriter", "Печатная машинка"), + ("layer.applyTextPreset", "Fade Up Characters", "Появление символов"), + ("layer.applyTextPreset", "Bounce In Words", "Появление слов с отскоком"), + ("layer.applyTextPreset", "Tracking In", "Трекинг внутрь"), + ("layer.applyTextPreset", "Scramble", "Перемешивание"), + ("layer.applyTextPreset", "Blur In", "Появление из размытия"), + ("layer.applyTextPreset", "Jitter", "Дрожание"), + ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), + ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), + ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), + ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Преобразовать звук в ключевые кадры"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Преобразовать выражение в ключевые кадры"), + ("keys.easyEase", "Easy Ease", "Плавное смягчение"), + ("keys.easyEaseIn", "Easy Ease In", "Плавное замедление"), + ("keys.easyEaseOut", "Easy Ease Out", "Плавное ускорение"), + ("keys.exponentialScale", "Exponential Scale", "Экспоненциальный масштаб"), + ("keys.rpfCameraImport", "RPF Camera Import", "Импорт камеры RPF"), + ("layer.sequence", "Sequence Layers...", "Разложить слои последовательно..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Обратить время ключевых кадров"), + ("", "Animate Text", "Анимировать текст"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Включить 3D для каждого символа"), + ("layer.addTextAnimator", "Anchor Point", "Опорная точка"), + ("layer.addTextAnimator", "Position", "Положение"), + ("layer.addTextAnimator", "Scale", "Масштаб"), + ("layer.addTextAnimator", "Skew", "Наклон"), + ("layer.addTextAnimator", "Rotation", "Вращение"), + ("layer.addTextAnimator", "Opacity", "Непрозрачность"), + ("layer.addTextAnimator", "All Transform Properties", "Все свойства трансформирования"), + ("", "Fill Color", "Цвет заливки"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Цветовой тон"), + ("layer.addTextAnimator", "Saturation", "Насыщенность"), + ("layer.addTextAnimator", "Brightness", "Яркость"), + ("", "Stroke Color", "Цвет обводки"), + ("layer.addTextAnimator", "Stroke Width", "Толщина обводки"), + ("layer.addTextAnimator", "Tracking", "Трекинг"), + ("layer.addTextAnimator", "Line Anchor", "Опорная точка строки"), + ("layer.addTextAnimator", "Line Spacing", "Межстрочный интервал"), + ("layer.addTextAnimator", "Character Offset", "Смещение символов"), + ("layer.addTextAnimator", "Character Value", "Код символа"), + ("layer.addTextAnimator", "Blur", "Размытие"), + ("text.animatorFontAxes", "Variable Font Axes", "Оси вариативного шрифта"), + ("", "Add Text Selector", "Добавить селектор текста"), + ("text.addSelector", "Range", "Диапазон"), + ("text.addSelector", "Wiggly", "Дрожащий"), + ("text.addSelector", "Expression", "Выражение"), + ("text.removeAllAnimators", "Remove All Text Animators", "Удалить все аниматоры текста"), + ("prop.setExpression", "Add Expression", "Добавить выражение"), + ("essential.addProperty", "Add Property to Essential Graphics", "Добавить свойство в основную графику"), + ("prop.separateDimensions", "Separate Dimensions", "Разделить измерения"), + ("track.camera", "Track Camera", "Отслеживать камеру"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Стабилизатор искажений VFX"), + ("track.motion", "Track Motion", "Отслеживать движение"), + ("track.stabilize", "Stabilize Motion", "Стабилизировать движение"), + ("track.property", "Track this Property", "Отслеживать это свойство"), + ("anim.reveal", "Reveal Properties with Keyframes", "Показать свойства с ключевыми кадрами"), + ("anim.reveal", "Reveal Properties with Animation", "Показать анимированные свойства"), + ("anim.reveal", "Reveal All Modified Properties", "Показать все измененные свойства"), + ("", "View", "Просмотр"), + ("view.newViewer", "New Viewer", "Новое окно просмотра"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Разделить с новым закрепленным окном просмотра"), + ("view.zoomIn", "Zoom In", "Увеличить"), + ("view.zoomOut", "Zoom Out", "Уменьшить"), + ("", "Resolution", "Разрешение"), + ("view.res.full", "Full", "Полное"), + ("view.res.half", "Half", "Половина"), + ("view.res.third", "Third", "Треть"), + ("view.res.quarter", "Quarter", "Четверть"), + ("view.res.custom", "Custom...", "Настраиваемое..."), + ("view.displayColorManagement", "Use Display Color Management", "Использовать управление цветом дисплея"), + ("", "Simulate Output", "Имитация вывода"), + ("view.simulateOutput", "No Output Simulation", "Без имитации вывода"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Устаревший Macintosh RGB (гамма 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Стандартный интернет-RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Линейный (гамма 1.0)"), + ("view.customRgb", "My Custom RGB...", "Свой RGB..."), + ("view.simulateOutput", "Custom...", "Настраиваемый..."), + ("view.rulers", "Show Rulers", "Показать линейки"), + ("", "Panel Background Color", "Цвет фона панели"), + ("view.panelBackground", "Black", "Черный"), + ("view.panelBackground", "Dark Gray", "Темно-серый"), + ("view.panelBackground", "Medium Gray (Default)", "Средне-серый (по умолчанию)"), + ("view.panelBackground", "Light Gray", "Светло-серый"), + ("view.panelBackground", "White", "Белый"), + ("view.panelBackground", "Custom", "Свой"), + ("view.panelBackground", "Select Custom Background Color...", "Выбрать цвет фона..."), + ("view.guides", "Show Guides", "Показать направляющие"), + ("view.snapToGuides", "Snap to Guides", "Привязывать к направляющим"), + ("view.lockGuides", "Lock Guides", "Закрепить направляющие"), + ("view.addGuide", "Add Guide...", "Добавить направляющую..."), + ("view.clearGuides", "Clear Guides", "Очистить направляющие"), + ("view.importGuides", "Import Guides...", "Импортировать направляющие..."), + ("view.exportGuides", "Export Guides...", "Экспортировать направляющие..."), + ("view.grid", "Show Grid", "Показать сетку"), + ("view.snapToGrid", "Snap to Grid", "Привязывать к сетке"), + ("view.snapping", "Snapping", "Привязывание"), + ("view.options", "View Options...", "Параметры просмотра..."), + ("view.layerControls", "Show Layer Controls", "Показать элементы управления слоя"), + ("", "Switch View Layout", "Сменить компоновку видов"), + ("view.layout", "1 View", "1 вид"), + ("view.layout", "2 Views", "2 вида"), + ("view.layout", "4 Views", "4 вида"), + ("view.shareViewOptions", "Share View Options", "Общие параметры просмотра"), + ("", "Switch 3D View", "Сменить 3D-вид"), + ("view.3d.activeCamera", "Active Camera", "Активная камера"), + ("view.3d.default", "Default", "По умолчанию"), + ("view.3d.front", "Front", "Спереди"), + ("view.3d.left", "Left", "Слева"), + ("view.3d.top", "Top", "Сверху"), + ("view.3d.back", "Back", "Сзади"), + ("view.3d.right", "Right", "Справа"), + ("view.3d.bottom", "Bottom", "Снизу"), + ("view.3d.custom1", "Custom View 1", "Пользовательский вид 1"), + ("view.3d.custom2", "Custom View 2", "Пользовательский вид 2"), + ("view.3d.custom3", "Custom View 3", "Пользовательский вид 3"), + ("", "Assign Shortcut to 3D View", "Назначить сочетание клавиш 3D-виду"), + ("view.3d.last", "Switch to Last 3D View", "Вернуться к последнему 3D-виду"), + ("view.lookAtSelected", "Look at Selected Layers", "Навести на выделенные слои"), + ("view.lookAtAll", "Look at All Layers", "Навести на все слои"), + ("time.set", "Go to Time...", "Перейти ко времени..."), + ("view.fullScreen", "Enter Full Screen", "Перейти в полноэкранный режим"), + ("", "Window", "Окно"), + ("", "Workspace", "Рабочая среда"), + ("window.workspace", "Default", "По умолчанию"), + ("window.workspace", "Review", "Рецензирование"), + ("window.workspace", "Learn", "Обучение"), + ("window.workspace", "Small Screen", "Малый экран"), + ("window.workspace", "Standard", "Стандартная"), + ("window.workspace", "All Panels", "Все панели"), + ("window.workspace", "Animation", "Анимация"), + ("window.workspace", "Color", "Цвет"), + ("window.workspace", "Effects", "Эффекты"), + ("window.workspace", "Essential Graphics", "Основная графика"), + ("window.workspace", "Minimal", "Минимальная"), + ("window.workspace", "Motion Tracking", "Отслеживание движения"), + ("window.workspace", "Paint", "Рисование"), + ("window.workspace", "Text", "Текст"), + ("window.workspace", "Undocked Panels", "Отсоединенные панели"), + ("window.resetWorkspace", "Reset to Saved Layout", "Вернуть сохраненную компоновку"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Сохранить изменения этой рабочей среды"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Сохранить как новую рабочую среду..."), + ("window.editWorkspaces", "Edit Workspaces...", "Редактировать рабочие среды..."), + ("", "Assign Shortcut to Workspace", "Назначить сочетание клавиш рабочей среде"), + ("window.panel", "Align", "Выравнивание"), + ("window.panel", "Audio", "Звук"), + ("window.panel", "Brushes", "Кисти"), + ("window.panel", "Character", "Символ"), + ("window.panel", "Content-Aware Fill", "Заливка с учетом содержимого"), + ("window.panel", "Effects & Presets", "Эффекты и заготовки"), + ("window.panel", "Essential Graphics", "Основная графика"), + ("window.panel", "Info", "Информация"), + ("help.inAppTutorials", "Learn", "Обучение"), + ("window.panel", "Lumetri Scopes", "Измерители Lumetri"), + ("window.panel", "Mask Interpolation", "Интерполяция маски"), + ("window.panel", "Media Browser", "Обозреватель медиа"), + ("window.panel", "Metadata", "Метаданные"), + ("window.panel", "Motion Sketch", "Эскиз движения"), + ("window.panel", "Paint", "Рисование"), + ("window.panel", "Paragraph", "Абзац"), + ("window.panel", "Preview", "Предварительный просмотр"), + ("window.panel", "Progress", "Ход выполнения"), + ("window.panel", "Properties", "Свойства"), + ("window.panel", "Script Console", "Консоль скриптов"), + ("window.panel", "Smoother", "Сглаживание"), + ("window.panel", "Tools", "Инструменты"), + ("window.panel", "Tracker", "Трекер"), + ("window.panel", "Wiggler", "Колебания"), + ("window.panel", "Composition", "Композиция"), + ("window.panel", "Flowchart", "Блок-схема"), + ("window.panel", "Footage", "Материал"), + ("window.panel", "Layer", "Слой"), + ("window.panel", "Project", "Проект"), + ("window.panel", "Render Queue", "Очередь визуализации"), + ("window.panel", "Timeline", "Шкала времени"), + ("window.panel", "Create Nulls From Paths", "Создание нулевых объектов из контуров"), + ("window.panel", "VR Comp Editor", "Редактор VR-композиций"), + ("", "Help", "Справка"), + ("help.docs", "EffectCraft Help...", "Справка EffectCraft..."), + ("help.docs", "Scripting Help...", "Справка по скриптам..."), + ("help.docs", "Expression Reference...", "Справочник по выражениям..."), + ("help.docs", "Effect Reference...", "Справочник по эффектам..."), + ("anim.browsePresets", "Animation Presets...", "Заготовки анимации..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Сочетания клавиш..."), + ("help.inAppTutorials", "In-App Tutorials...", "Уроки в приложении..."), + ("help.onlineTutorials", "Online Tutorials...", "Онлайн-уроки..."), + ("help.systemReport", "System Compatibility Report...", "Отчет о совместимости системы..."), + ("help.enableLogging", "Enable Logging", "Включить ведение журнала"), + ("help.revealLogFile", "Reveal Logging File", "Показать файл журнала"), + ("help.discord", "Join the ArtCraft Discord...", "Присоединяйтесь к Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Оставить отзыв..."), + ("help.website", "ArtCraft Website", "Сайт ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Главная страница EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft на GitHub"), + ("file.openDemoProject", "Open Demo Project", "Открыть демонстрационный проект"), +]; + +/// French (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const FRENCH: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Copier l'image dans le presse-papiers"), + ("", "Front", "Avant"), + ("", "Bevel", "Biseau"), + ("", "Side", "Côté"), + ("", "Back", "Arrière"), + ("layer.addTextAnimator", "Color", "Couleur"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "À propos d’EffectCraft..."), + ("", "Settings...", "Préférences..."), + ("app.settings", "General...", "Général..."), + ("app.settings", "Startup & Repair...", "Démarrage et réparation..."), + ("app.settings", "Project...", "Projet..."), + ("app.settings", "Composition...", "Composition..."), + ("app.settings", "Previews...", "Prévisualisations..."), + ("app.settings", "Appearance...", "Apparence..."), + ("app.settings", "Grids & Guides...", "Grilles et repères..."), + ("app.settings", "Labels...", "Étiquettes..."), + ("app.settings", "Type...", "Texte..."), + ("app.settings", "Import...", "Importation..."), + ("app.settings", "Export...", "Exportation..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Disque..."), + ("app.settings", "Memory & CPU...", "Mémoire et CPU..."), + ("app.settings", "Video...", "Vidéo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Scripts et expressions..."), + ("app.hide", "Hide EffectCraft", "Masquer EffectCraft"), + ("app.hideOthers", "Hide Others", "Masquer les autres"), + ("app.showAll", "Show All", "Tout afficher"), + ("app.quit", "Quit EffectCraft", "Quitter EffectCraft"), + ("", "File", "Fichier"), + ("", "New", "Nouveau"), + ("file.newProject", "New Project", "Nouveau projet"), + ("file.newFromTemplate", "New Project from Template...", "Nouveau projet à partir d’un modèle..."), + ("project.newFolder", "New Folder", "Nouveau dossier"), + ("file.open", "Open Project...", "Ouvrir un projet..."), + ("", "Open Recent", "Ouvrir les projets récents"), + ("file.clearRecent", "Clear Recent Projects", "Effacer la liste des projets récents"), + ("file.close", "Close", "Fermer"), + ("file.closeProject", "Close Project", "Fermer le projet"), + ("file.save", "Save", "Enregistrer"), + ("", "Save As", "Enregistrer sous"), + ("file.saveAs", "Save As...", "Enregistrer sous..."), + ("file.saveCopy", "Save a Copy...", "Enregistrer une copie..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Enregistrer une copie en XML..."), + ("templates.saveAs", "Save as Template...", "Enregistrer en tant que modèle..."), + ("file.incrementAndSave", "Increment and Save", "Incrémenter et enregistrer"), + ("file.revert", "Revert", "Version précédente"), + ("", "Import", "Importer"), + ("file.import", "File...", "Fichier..."), + ("file.importMultiple", "Multiple Files...", "Plusieurs fichiers..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Espace réservé..."), + ("file.importSolid", "Solid...", "Solide..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Point de fuite (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Modèle de graphismes essentiels..."), + ("", "Import Recent Footage", "Importer le métrage récent"), + ("file.clearRecentFootage", "Clear Recent Footage", "Effacer la liste des métrages récents"), + ("", "Export", "Exporter"), + ("renderQueue.add", "Add to Render Queue", "Ajouter à la file d’attente de rendu"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Modèle de graphismes essentiels..."), + ("app.find", "Find", "Rechercher"), + ("layer.addItem", "Add Footage to Comp", "Ajouter le métrage à la composition"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nouvelle composition à partir de la sélection..."), + ("", "Dependencies", "Dépendances"), + ("file.collectFiles", "Collect Files...", "Collecter les fichiers..."), + ("file.consolidateFootage", "Consolidate All Footage", "Consolider tout le métrage"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Supprimer le métrage inutilisé"), + ("file.reduceProject", "Reduce Project", "Réduire le projet"), + ("file.findMissing", "Find Missing Effects", "Rechercher les effets manquants"), + ("file.findMissing", "Find Missing Fonts", "Rechercher les polices manquantes"), + ("file.findMissing", "Find Missing Footage", "Rechercher le métrage manquant"), + ("file.relinkFootage", "Relink Missing Footage...", "Reconnecter le métrage manquant..."), + ("file.watchFolder", "Watch Folder...", "Dossier de contrôle..."), + ("", "Scripts", "Scripts"), + ("file.installScript", "Install Script File...", "Installer un fichier de script..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."), + ("file.runScript", "Run Script File...", "Exécuter un fichier de script..."), + ("", "Create Proxy", "Créer un proxy"), + ("file.createProxy", "Still...", "Image fixe..."), + ("file.createProxy", "Movie...", "Vidéo..."), + ("", "Set Proxy", "Définir le proxy"), + ("file.setProxy", "File...", "Fichier..."), + ("file.setProxyNone", "None", "Aucun"), + ("", "Interpret Footage", "Interpréter le métrage"), + ("file.interpretFootage", "Main...", "Principal..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Mémoriser l’interprétation"), + ("file.applyInterpretation", "Apply Interpretation", "Appliquer l’interprétation"), + ("", "Replace Footage", "Remplacer le métrage"), + ("file.replaceFootage", "File...", "Fichier..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Espace réservé..."), + ("file.replaceWithSolid", "Solid...", "Solide..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Par une composition multi-calques"), + ("file.reloadFootage", "Reload Footage", "Recharger le métrage"), + ("file.revealInFinder", "Reveal in Finder", "Révéler dans le Finder"), + ("file.projectSettings", "Project Settings...", "Paramètres du projet..."), + ("app.quit", "Exit", "Quitter"), + ("", "Edit", "Édition"), + ("edit.undo", "Undo", "Annuler"), + ("edit.redo", "Redo", "Rétablir"), + ("", "History", "Historique"), + ("app.commandPalette", "Quick Apply...", "Application rapide..."), + ("edit.cut", "Cut", "Couper"), + ("edit.copy", "Copy", "Copier"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Copier avec liens de propriétés"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Copier avec liens de propriétés relatifs"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Copier uniquement l’expression"), + ("edit.paste", "Paste", "Coller"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Coller les images clés inversées"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Coller le texte avec la mise en forme correspondante"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Coller uniquement la mise en forme du texte"), + ("edit.clear", "Clear", "Effacer"), + ("edit.duplicate", "Duplicate", "Dupliquer"), + ("edit.splitLayer", "Split Layer", "Fractionner le calque"), + ("edit.liftWorkArea", "Lift Work Area", "Lever la zone de travail"), + ("edit.extractWorkArea", "Extract Work Area", "Extraire la zone de travail"), + ("edit.selectAll", "Select All", "Tout sélectionner"), + ("edit.deselectAll", "Deselect All", "Tout désélectionner"), + ("", "Label", "Étiquette"), + ("edit.selectLabelGroup", "Select Label Group", "Sélectionner le groupe d’étiquettes"), + ("edit.label", "None", "Aucune"), + ("edit.label", "Red", "Rouge"), + ("edit.label", "Yellow", "Jaune"), + ("edit.label", "Aqua", "Aqua"), + ("edit.label", "Pink", "Rose"), + ("edit.label", "Lavender", "Lavande"), + ("edit.label", "Peach", "Pêche"), + ("edit.label", "Sea Foam", "Vert d’eau"), + ("edit.label", "Blue", "Bleu"), + ("edit.label", "Green", "Vert"), + ("edit.label", "Purple", "Violet"), + ("edit.label", "Orange", "Orange"), + ("edit.label", "Brown", "Marron"), + ("edit.label", "Fuchsia", "Fuchsia"), + ("edit.label", "Cyan", "Cyan"), + ("edit.label", "Sandstone", "Sable"), + ("edit.label", "Dark Green", "Vert foncé"), + ("app.settings", "Edit Label Colors...", "Modifier les couleurs des étiquettes..."), + ("", "Select Keyframe Label Group", "Sélectionner le groupe d’étiquettes d’images clés"), + ("keys.selectLabelGroup", "On Selected Layers", "Sur les calques sélectionnés"), + ("keys.selectLabelGroup", "On All Layers", "Sur tous les calques"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Images clés visibles sur les calques sélectionnés"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Images clés visibles sur tous les calques"), + ("", "Purge", "Vider"), + ("edit.purge", "All Cache...", "Tout le cache..."), + ("edit.purge", "All Memory & Disk Cache...", "Toute la mémoire et le cache disque..."), + ("edit.purge", "All Memory", "Toute la mémoire"), + ("edit.purge", "All Disk Cache...", "Tout le cache disque..."), + ("edit.purge", "All 3D Cache...", "Tout le cache 3D..."), + ("edit.purgeUndo", "Undo", "Historique d’annulation"), + ("edit.purge", "Image Cache Memory", "Mémoire cache des images"), + ("edit.purge", "Snapshot", "Instantané"), + ("edit.editOriginal", "Edit Original...", "Modifier l’original..."), + ("", "Templates", "Modèles"), + ("app.templates", "Render Settings...", "Paramètres de rendu..."), + ("app.templates", "Output Module...", "Module de sortie..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Raccourcis clavier"), + ("", "Preferences", "Préférences"), + ("", "Composition", "Composition"), + ("comp.new", "New Composition...", "Nouvelle composition..."), + ("comp.settings", "Composition Settings...", "Paramètres de composition..."), + ("comp.setPosterTime", "Set Poster Time", "Définir l’image affiche"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Raccourcir la composition à la zone de travail"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Rogner la composition à la zone d’intérêt"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Rogner la composition aux limites des calques sélectionnés"), + ("render.addOutputModule", "Add Output Module", "Ajouter un module de sortie"), + ("", "Preview", "Aperçu"), + ("playback.toggle", "Play Current Preview", "Lire l’aperçu actuel"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Mettre en cache les images en cas d’inactivité"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Enregistrer l’image sous"), + ("comp.saveFrameAs", "File...", "Fichier..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Calques Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Prérendu..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Enregistrer l’aperçu actuel..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Ouvrir dans Graphiques essentiels"), + ("", "Responsive Design — Time", "Responsive Design — Temps"), + ("comp.responsiveTime", "Create Intro", "Créer une intro"), + ("comp.responsiveTime", "Create Outro", "Créer une outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Créer une région protégée à partir de la zone de travail"), + ("comp.flowchart", "Composition Flowchart", "Organigramme de composition"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Mini-organigramme de composition"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Créer un environnement VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extraire le cubemap..."), + ("", "Layer", "Calque"), + ("layer.newText", "Text", "Texte"), + ("layer.newSolid", "Solid...", "Solide..."), + ("layer.newLight", "Light...", "Lumière..."), + ("layer.newCamera", "Camera...", "Caméra..."), + ("layer.newNull", "Null Object", "Objet nul"), + ("layer.newShape", "Shape Layer", "Calque de forme"), + ("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."), + ("layer.new3dPrimitive", "Cube", "Cube"), + ("layer.new3dPrimitive", "Sphere", "Sphère"), + ("layer.new3dPrimitive", "Plane", "Plan"), + ("layer.new3dPrimitive", "Torus", "Tore"), + ("layer.new3dPrimitive", "Cone", "Cône"), + ("layer.new3dPrimitive", "Cylinder", "Cylindre"), + ("layer.settings", "Layer Settings...", "Paramètres du calque..."), + ("layer.openLayer", "Open Layer", "Ouvrir le calque"), + ("layer.openSource", "Open Layer Source", "Ouvrir la source du calque"), + ("layer.revealInFinder", "Reveal in Finder", "Afficher dans le Finder"), + ("", "Mask", "Masque"), + ("layer.addMask", "New Mask", "Nouveau masque"), + ("layer.mask.shape", "Mask Shape...", "Forme du masque..."), + ("layer.mask.set", "Mask Feather...", "Contour progressif du masque..."), + ("layer.mask.set", "Mask Opacity...", "Opacité du masque..."), + ("layer.mask.set", "Mask Expansion...", "Extension du masque..."), + ("layer.mask.reset", "Reset Mask", "Réinitialiser le masque"), + ("layer.mask.remove", "Remove Mask", "Supprimer le masque"), + ("layer.mask.removeAll", "Remove All Masks", "Supprimer tous les masques"), + ("track.mask", "Track Mask", "Suivi du masque"), + ("", "Mode", "Mode"), + ("layer.mask.mode", "None", "Aucun"), + ("layer.mask.mode", "Add", "Ajouter"), + ("layer.mask.mode", "Subtract", "Soustraire"), + ("layer.mask.mode", "Intersect", "Intersection"), + ("layer.mask.mode", "Lighten", "Éclaircir"), + ("layer.mask.mode", "Darken", "Obscurcir"), + ("layer.mask.mode", "Difference", "Différence"), + ("layer.mask.invert", "Inverted", "Inversé"), + ("layer.mask.lock", "Locked", "Verrouillé"), + ("", "Motion Blur", "Flou de mouvement"), + ("layer.mask.motionBlur", "Same As Layer", "Identique au calque"), + ("layer.mask.motionBlur", "On", "Activé"), + ("layer.mask.motionBlur", "Off", "Désactivé"), + ("", "Feather Falloff", "Atténuation du contour progressif"), + ("layer.mask.featherFalloff", "Smooth", "Lisse"), + ("layer.mask.featherFalloff", "Linear", "Linéaire"), + ("layer.mask.unlockAll", "Unlock All Masks", "Déverrouiller tous les masques"), + ("layer.mask.lockOthers", "Lock Other Masks", "Verrouiller les autres masques"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Masquer les masques verrouillés"), + ("", "Mask and Shape Path", "Trajectoire de masque et de forme"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Fermé"), + ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trajectoire Bézier"), + ("path.setFirstVertex", "Set First Vertex", "Définir le premier sommet"), + ("path.freeTransform", "Free Transform Points", "Transformation libre des points"), + ("", "Quality", "Qualité"), + ("layer.quality", "Best", "Optimale"), + ("layer.quality", "Draft", "Brouillon"), + ("layer.quality", "Wireframe", "Fil de fer"), + ("layer.sampling", "Bilinear", "Bilinéaire"), + ("layer.sampling", "Bicubic", "Bicubique"), + ("", "Switches", "Commutateurs"), + ("layer.hideOtherVideo", "Hide Other Video", "Masquer les autres vidéos"), + ("layer.showAllVideo", "Show All Video", "Afficher toutes les vidéos"), + ("layer.unlockAll", "Unlock All Layers", "Déverrouiller tous les calques"), + ("layer.expressions", "Enable Expressions", "Activer les expressions"), + ("layer.expressions", "Disable Expressions", "Désactiver les expressions"), + ("layer.setSwitch", "Shy", "Timide"), + ("layer.setSwitch", "Lock", "Verrouiller"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Vidéo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Effet"), + ("layer.setSwitch", "Collapse", "Réduire les transformations"), + ("layer.setSwitch", "Motion Blur", "Flou de mouvement"), + ("layer.setSwitch", "Adjustment Layer", "Calque de réglage"), + ("", "Transform", "Transformation"), + ("layer.transform", "Reset", "Réinitialiser"), + ("layer.setTransform", "Anchor Point...", "Point d’ancrage..."), + ("layer.setTransform", "Position...", "Position..."), + ("layer.setTransform", "Scale...", "Échelle..."), + ("layer.setTransform", "Orientation...", "Orientation..."), + ("layer.setTransform", "Rotation...", "Rotation..."), + ("layer.setTransform", "Opacity...", "Opacité..."), + ("layer.transform", "Flip Horizontal", "Miroir horizontal"), + ("layer.transform", "Flip Vertical", "Miroir vertical"), + ("layer.transform", "Center In View", "Centrer dans la vue"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Centrer le point d’ancrage dans le contenu du calque"), + ("layer.transform", "Fit to Comp", "Ajuster à la composition"), + ("layer.transform", "Fit to Comp Width", "Ajuster à la largeur de la composition"), + ("layer.transform", "Fit to Comp Height", "Ajuster à la hauteur de la composition"), + ("layer.autoOrient", "Auto-Orient...", "Orientation automatique..."), + ("", "Time", "Temps"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Activer le remappage temporel"), + ("layer.timeReverse", "Time-Reverse Layer", "Inverser le temps du calque"), + ("layer.timeStretch", "Time Stretch...", "Extension temporelle..."), + ("layer.freezeFrame", "Freeze Frame", "Image figée"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Figer à la dernière image"), + ("layer.alignVideoToData", "Align Video to Data", "Aligner la vidéo sur les données"), + ("", "Frame Blending", "Fusion d’images"), + ("layer.frameBlending", "Off", "Désactivé"), + ("layer.frameBlending", "Frame Mix", "Mélange d’images"), + ("layer.frameBlending", "Pixel Motion", "Mouvement de pixel"), + ("layer.setSwitch", "3D Layer", "Calque 3D"), + ("layer.setSwitch", "Guide Layer", "Calque de repère"), + ("layer.environment", "Environment Layer", "Calque d’environnement"), + ("", "Markers", "Repères"), + ("layer.addMarker", "Add Marker", "Ajouter un repère"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Mettre à jour les repères à partir de la source"), + ("layer.markersLock", "Lock Markers", "Verrouiller les repères"), + ("layer.deleteAllMarkers", "Delete All Markers", "Supprimer tous les repères"), + ("layer.setSwitch", "Preserve Transparency", "Conserver la transparence"), + ("", "Blending Mode", "Mode de fusion"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Fondu"), + ("layer.setBlendMode", "Dancing Dissolve", "Fondu dansant"), + ("layer.setBlendMode", "Darken", "Obscurcir"), + ("layer.setBlendMode", "Multiply", "Produit"), + ("layer.setBlendMode", "Color Burn", "Densité couleur +"), + ("layer.setBlendMode", "Classic Color Burn", "Densité couleur + classique"), + ("layer.setBlendMode", "Linear Burn", "Densité linéaire +"), + ("layer.setBlendMode", "Darker Color", "Couleur plus foncée"), + ("layer.setBlendMode", "Add", "Ajouter"), + ("layer.setBlendMode", "Lighten", "Éclaircir"), + ("layer.setBlendMode", "Screen", "Superposition"), + ("layer.setBlendMode", "Color Dodge", "Densité couleur -"), + ("layer.setBlendMode", "Classic Color Dodge", "Densité couleur - (classique)"), + ("layer.setBlendMode", "Linear Dodge", "Densité linéaire -"), + ("layer.setBlendMode", "Lighter Color", "Couleur plus claire"), + ("layer.setBlendMode", "Overlay", "Incrustation"), + ("layer.setBlendMode", "Soft Light", "Lumière tamisée"), + ("layer.setBlendMode", "Hard Light", "Lumière crue"), + ("layer.setBlendMode", "Linear Light", "Lumière linéaire"), + ("layer.setBlendMode", "Vivid Light", "Lumière vive"), + ("layer.setBlendMode", "Pin Light", "Lumière ponctuelle"), + ("layer.setBlendMode", "Hard Mix", "Mélange maximal"), + ("layer.setBlendMode", "Difference", "Différence"), + ("layer.setBlendMode", "Classic Difference", "Différence classique"), + ("layer.setBlendMode", "Exclusion", "Exclusion"), + ("layer.setBlendMode", "Subtract", "Soustraction"), + ("layer.setBlendMode", "Divide", "Division"), + ("layer.setBlendMode", "Hue", "Teinte"), + ("layer.setBlendMode", "Saturation", "Saturation"), + ("layer.setBlendMode", "Color", "Couleur"), + ("layer.setBlendMode", "Luminosity", "Luminosité"), + ("layer.setBlendMode", "Stencil Alpha", "Modèle alpha"), + ("layer.setBlendMode", "Stencil Luma", "Luminance du modèle"), + ("layer.setBlendMode", "Silhouette Alpha", "Silhouette alpha"), + ("layer.setBlendMode", "Silhouette Luma", "Luminance de silhouette"), + ("layer.setBlendMode", "Alpha Add", "Ajout alpha"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescent prémultiplié"), + ("layer.setBlendMode", "Next Blending Mode", "Mode de fusion suivant"), + ("layer.setBlendMode", "Previous Blending Mode", "Mode de fusion précédent"), + ("", "Track Matte", "Estampe"), + ("layer.trackMatte", "No Track Matte", "Aucune estampe"), + ("layer.trackMatte", "Alpha Matte", "Estampe alpha"), + ("layer.trackMatte", "Alpha Inverted Matte", "Estampe alpha inversée"), + ("layer.trackMatte", "Luma Matte", "Estampe luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Estampe luma inversée"), + ("layer.trackMatte", "Matte with Layer Above", "Estampe avec le calque au-dessus"), + ("layer.trackMatte", "Matte with Layer Below", "Estampe avec le calque en dessous"), + ("", "Layer Styles", "Styles de calque"), + ("layer.style.options", "Layer Style Options...", "Options de style de calque..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Convertir en styles modifiables"), + ("layer.style.showAll", "Show All", "Tout afficher"), + ("layer.style.removeAll", "Remove All", "Tout supprimer"), + ("layer.style.dropShadow", "Drop Shadow", "Ombre portée"), + ("layer.style.innerShadow", "Inner Shadow", "Ombre interne"), + ("layer.style.outerGlow", "Outer Glow", "Lueur externe"), + ("layer.style.innerGlow", "Inner Glow", "Lueur interne"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Biseautage et estampage"), + ("layer.style.satin", "Satin", "Satin"), + ("layer.style.colorOverlay", "Color Overlay", "Incrustation couleur"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Incrustation en dégradé"), + ("layer.style.stroke", "Stroke", "Contour"), + ("path.groupShapes", "Group Shapes", "Grouper les formes"), + ("path.ungroupShapes", "Ungroup Shapes", "Dissocier les formes"), + ("", "Arrange", "Disposition"), + ("layer.arrange", "Bring Layer to Front", "Placer le calque au premier plan"), + ("layer.arrange", "Bring Layer Forward", "Avancer le calque d'un cran"), + ("layer.arrange", "Send Layer Backward", "Reculer le calque d'un cran"), + ("layer.arrange", "Send Layer to Back", "Placer le calque en arrière-plan"), + ("", "Reveal", "Révéler"), + ("layer.revealSource", "Reveal Layer Source in Project", "Révéler la source du calque dans le projet"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Révéler le calque dans l'organigramme du projet"), + ("comp.revealInProject", "Reveal Composition in Project", "Révéler la composition dans le projet"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Révéler les erreurs d'expression"), + ("", "Create", "Créer"), + ("layer.create", "Convert to Editable Text", "Convertir en texte modifiable"), + ("layer.create", "Create Shapes from Text", "Créer des formes à partir du texte"), + ("layer.create", "Create Masks from Text", "Créer des masques à partir du texte"), + ("layer.create", "Create Shapes from Vector Layer", "Créer des formes à partir du calque vectoriel"), + ("layer.create", "Create Keyframes from Data", "Créer des images clés à partir des données"), + ("layer.create", "Null Controllers for Positional Points", "Objets nuls de contrôle pour les points de position"), + ("layer.create", "Null Controllers for Path Points", "Objets nuls de contrôle pour les points de tracé"), + ("layer.create", "Nulls Following Path Points", "Objets nuls suivant les points du tracé"), + ("layer.create", "Null Tracing Along Path", "Objet nul se déplaçant le long du tracé"), + ("layer.create", "Create 3D Layer Instance", "Créer une instance de calque 3D"), + ("", "Camera", "Caméra"), + ("camera.fromView", "Create Camera from 3D View", "Créer une caméra à partir de la vue 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Créer un rig stéréo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Créer un objet nul d'orbite"), + ("camera.fromModel", "Create Cameras from 3D Model", "Créer des caméras à partir du modèle 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Lier la distance focale au point d'intérêt"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Lier la distance focale au calque"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Définir la distance focale sur le calque"), + ("layer.cameraSettings", "Camera Settings...", "Paramètres de caméra..."), + ("view.reset3DView", "Reset 3D View", "Réinitialiser la vue 3D"), + ("", "Light", "Lumière"), + ("light.fromModel", "Create Lights from 3D Model", "Créer des lumières à partir du modèle 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Contrôler la lumière avec la caméra"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Créer un calque d'arrière-plan de lumière d'environnement"), + ("", "Material", "Matériau"), + ("material.revealSource", "Reveal Material Source in Project", "Révéler la source du matériau dans le projet"), + ("material.reset", "Reset Material", "Réinitialiser le matériau"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Dupliquer et attribuer le matériau"), + ("layer.autoTrace", "Auto-trace...", "Tracé automatique..."), + ("layer.precompose", "Pre-compose...", "Précomposer..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Détection des coupes de scène..."), + ("", "Effect", "Effet"), + ("window.panel", "Effect Controls", "Effets appliqués"), + ("effect.applyLast", "Last Effect", "Dernier effet"), + ("effect.removeAll", "Remove All", "Tout supprimer"), + ("effect.manage", "Manage Effects...", "Gérer les effets..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Charger un plug-in d'effet..."), + ("", "Animation", "Animation"), + ("anim.savePreset", "Save Animation Preset...", "Enregistrer une animation prédéfinie..."), + ("anim.applyPreset", "Apply Animation Preset...", "Appliquer une animation prédéfinie..."), + ("", "Recent Animation Presets", "Animations prédéfinies récentes"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Effacer les animations prédéfinies récentes"), + ("anim.browsePresets", "Browse Presets...", "Parcourir les animations prédéfinies..."), + ("", "Text Animation Presets", "Animations prédéfinies de texte"), + ("layer.applyTextPreset", "Typewriter", "Machine à écrire"), + ("layer.applyTextPreset", "Fade Up Characters", "Fondu entrant des caractères"), + ("layer.applyTextPreset", "Bounce In Words", "Apparition des mots avec rebond"), + ("layer.applyTextPreset", "Tracking In", "Interlettrage progressif"), + ("layer.applyTextPreset", "Scramble", "Brouillage"), + ("layer.applyTextPreset", "Blur In", "Flou entrant"), + ("layer.applyTextPreset", "Jitter", "Tremblement"), + ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), + ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), + ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), + ("", "Keyframe Assistant", "Assistant d'images clés"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Convertir l'audio en images clés"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Convertir l'expression en images clés"), + ("keys.easyEase", "Easy Ease", "Lissage"), + ("keys.easyEaseIn", "Easy Ease In", "Lissage entrant"), + ("keys.easyEaseOut", "Easy Ease Out", "Lissage sortant"), + ("keys.exponentialScale", "Exponential Scale", "Échelle exponentielle"), + ("keys.rpfCameraImport", "RPF Camera Import", "Importation de caméra RPF"), + ("layer.sequence", "Sequence Layers...", "Enchaîner les calques..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Inverser le temporel des images clés"), + ("", "Animate Text", "Animer le texte"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Activer la 3D par caractère"), + ("layer.addTextAnimator", "Anchor Point", "Point d'ancrage"), + ("layer.addTextAnimator", "Position", "Position"), + ("layer.addTextAnimator", "Scale", "Échelle"), + ("layer.addTextAnimator", "Skew", "Inclinaison"), + ("layer.addTextAnimator", "Rotation", "Rotation"), + ("layer.addTextAnimator", "Opacity", "Opacité"), + ("layer.addTextAnimator", "All Transform Properties", "Toutes les propriétés de transformation"), + ("", "Fill Color", "Couleur de remplissage"), + ("layer.addTextAnimator", "RGB", "RVB"), + ("layer.addTextAnimator", "Hue", "Teinte"), + ("layer.addTextAnimator", "Saturation", "Saturation"), + ("layer.addTextAnimator", "Brightness", "Luminosité"), + ("", "Stroke Color", "Couleur du contour"), + ("layer.addTextAnimator", "Stroke Width", "Épaisseur du contour"), + ("layer.addTextAnimator", "Tracking", "Interlettrage"), + ("layer.addTextAnimator", "Line Anchor", "Ancrage de ligne"), + ("layer.addTextAnimator", "Line Spacing", "Interligne"), + ("layer.addTextAnimator", "Character Offset", "Décalage de caractère"), + ("layer.addTextAnimator", "Character Value", "Valeur de caractère"), + ("layer.addTextAnimator", "Blur", "Flou"), + ("text.animatorFontAxes", "Variable Font Axes", "Axes de police variable"), + ("", "Add Text Selector", "Ajouter un sélecteur de texte"), + ("text.addSelector", "Range", "Plage"), + ("text.addSelector", "Wiggly", "Ondulant"), + ("text.addSelector", "Expression", "Expression"), + ("text.removeAllAnimators", "Remove All Text Animators", "Supprimer tous les animateurs de texte"), + ("prop.setExpression", "Add Expression", "Ajouter une expression"), + ("essential.addProperty", "Add Property to Essential Graphics", "Ajouter la propriété aux graphiques essentiels"), + ("prop.separateDimensions", "Separate Dimensions", "Séparer les dimensions"), + ("track.camera", "Track Camera", "Suivi de caméra"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Stabilisateur de déformation VFX"), + ("track.motion", "Track Motion", "Suivi du mouvement"), + ("track.stabilize", "Stabilize Motion", "Stabiliser le mouvement"), + ("track.property", "Track this Property", "Effectuer le suivi de cette propriété"), + ("anim.reveal", "Reveal Properties with Keyframes", "Révéler les propriétés avec images clés"), + ("anim.reveal", "Reveal Properties with Animation", "Révéler les propriétés avec animation"), + ("anim.reveal", "Reveal All Modified Properties", "Révéler toutes les propriétés modifiées"), + ("", "View", "Affichage"), + ("view.newViewer", "New Viewer", "Nouveau visualiseur"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Fractionner avec un nouveau visualiseur verrouillé"), + ("view.zoomIn", "Zoom In", "Zoom avant"), + ("view.zoomOut", "Zoom Out", "Zoom arrière"), + ("", "Resolution", "Résolution"), + ("view.res.full", "Full", "Complète"), + ("view.res.half", "Half", "Moitié"), + ("view.res.third", "Third", "Tiers"), + ("view.res.quarter", "Quarter", "Quart"), + ("view.res.custom", "Custom...", "Personnalisée..."), + ("view.displayColorManagement", "Use Display Color Management", "Utiliser la gestion des couleurs de l’écran"), + ("", "Simulate Output", "Simuler la sortie"), + ("view.simulateOutput", "No Output Simulation", "Aucune simulation de sortie"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "RVB Macintosh hérité (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "RVB Internet standard (sRVB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Linéaire (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Mon RVB personnalisé..."), + ("view.simulateOutput", "Custom...", "Personnalisé..."), + ("view.rulers", "Show Rulers", "Afficher les règles"), + ("", "Panel Background Color", "Couleur d’arrière-plan du panneau"), + ("view.panelBackground", "Black", "Noir"), + ("view.panelBackground", "Dark Gray", "Gris foncé"), + ("view.panelBackground", "Medium Gray (Default)", "Gris moyen (par défaut)"), + ("view.panelBackground", "Light Gray", "Gris clair"), + ("view.panelBackground", "White", "Blanc"), + ("view.panelBackground", "Custom", "Personnalisée"), + ("view.panelBackground", "Select Custom Background Color...", "Sélectionner une couleur d’arrière-plan personnalisée..."), + ("view.guides", "Show Guides", "Afficher les repères"), + ("view.snapToGuides", "Snap to Guides", "Aimanter sur les repères"), + ("view.lockGuides", "Lock Guides", "Verrouiller les repères"), + ("view.addGuide", "Add Guide...", "Ajouter un repère..."), + ("view.clearGuides", "Clear Guides", "Effacer les repères"), + ("view.importGuides", "Import Guides...", "Importer des repères..."), + ("view.exportGuides", "Export Guides...", "Exporter des repères..."), + ("view.grid", "Show Grid", "Afficher la grille"), + ("view.snapToGrid", "Snap to Grid", "Aimanter sur la grille"), + ("view.snapping", "Snapping", "Aimantation"), + ("view.options", "View Options...", "Options d’affichage..."), + ("view.layerControls", "Show Layer Controls", "Afficher les contrôles de calque"), + ("", "Switch View Layout", "Changer la disposition des vues"), + ("view.layout", "1 View", "1 vue"), + ("view.layout", "2 Views", "2 vues"), + ("view.layout", "4 Views", "4 vues"), + ("view.shareViewOptions", "Share View Options", "Partager les options d’affichage"), + ("", "Switch 3D View", "Changer de vue 3D"), + ("view.3d.activeCamera", "Active Camera", "Caméra active"), + ("view.3d.default", "Default", "Par défaut"), + ("view.3d.front", "Front", "Face"), + ("view.3d.left", "Left", "Gauche"), + ("view.3d.top", "Top", "Dessus"), + ("view.3d.back", "Back", "Arrière"), + ("view.3d.right", "Right", "Droite"), + ("view.3d.bottom", "Bottom", "Dessous"), + ("view.3d.custom1", "Custom View 1", "Vue personnalisée 1"), + ("view.3d.custom2", "Custom View 2", "Vue personnalisée 2"), + ("view.3d.custom3", "Custom View 3", "Vue personnalisée 3"), + ("", "Assign Shortcut to 3D View", "Attribuer un raccourci à la vue 3D"), + ("view.3d.last", "Switch to Last 3D View", "Basculer vers la dernière vue 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Pointer sur les calques sélectionnés"), + ("view.lookAtAll", "Look at All Layers", "Pointer sur tous les calques"), + ("time.set", "Go to Time...", "Accéder au temps..."), + ("view.fullScreen", "Enter Full Screen", "Passer en plein écran"), + ("", "Window", "Fenêtre"), + ("", "Workspace", "Espace de travail"), + ("window.workspace", "Default", "Par défaut"), + ("window.workspace", "Review", "Révision"), + ("window.workspace", "Learn", "Apprentissage"), + ("window.workspace", "Small Screen", "Petit écran"), + ("window.workspace", "Standard", "Standard"), + ("window.workspace", "All Panels", "Tous les panneaux"), + ("window.workspace", "Animation", "Animation"), + ("window.workspace", "Color", "Couleur"), + ("window.workspace", "Effects", "Effets"), + ("window.workspace", "Essential Graphics", "Graphiques essentiels"), + ("window.workspace", "Minimal", "Minimal"), + ("window.workspace", "Motion Tracking", "Suivi de mouvement"), + ("window.workspace", "Paint", "Peinture"), + ("window.workspace", "Text", "Texte"), + ("window.workspace", "Undocked Panels", "Panneaux détachés"), + ("window.resetWorkspace", "Reset to Saved Layout", "Rétablir la disposition enregistrée"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Enregistrer les modifications de cet espace de travail"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Enregistrer comme nouvel espace de travail..."), + ("window.editWorkspaces", "Edit Workspaces...", "Modifier les espaces de travail..."), + ("", "Assign Shortcut to Workspace", "Attribuer un raccourci à l’espace de travail"), + ("window.panel", "Align", "Alignement"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pinceaux"), + ("window.panel", "Character", "Caractère"), + ("window.panel", "Content-Aware Fill", "Remplissage d’après le contenu"), + ("window.panel", "Effects & Presets", "Effets et programmes"), + ("window.panel", "Essential Graphics", "Graphiques essentiels"), + ("window.panel", "Info", "Informations"), + ("help.inAppTutorials", "Learn", "Apprentissage"), + ("window.panel", "Lumetri Scopes", "Oscilloscopes Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolation de masque"), + ("window.panel", "Media Browser", "Navigateur multimédia"), + ("window.panel", "Metadata", "Métadonnées"), + ("window.panel", "Motion Sketch", "Croquis de mouvement"), + ("window.panel", "Paint", "Peinture"), + ("window.panel", "Paragraph", "Paragraphe"), + ("window.panel", "Preview", "Prévisualisation"), + ("window.panel", "Progress", "Progression"), + ("window.panel", "Properties", "Propriétés"), + ("window.panel", "Script Console", "Console de scripts"), + ("window.panel", "Smoother", "Lisseur"), + ("window.panel", "Tools", "Outils"), + ("window.panel", "Tracker", "Suivi"), + ("window.panel", "Wiggler", "Onduleur"), + ("window.panel", "Composition", "Composition"), + ("window.panel", "Flowchart", "Organigramme"), + ("window.panel", "Footage", "Métrage"), + ("window.panel", "Layer", "Calque"), + ("window.panel", "Project", "Projet"), + ("window.panel", "Render Queue", "File d’attente de rendu"), + ("window.panel", "Timeline", "Chronologie"), + ("window.panel", "Create Nulls From Paths", "Créer des nuls à partir des tracés"), + ("window.panel", "VR Comp Editor", "Éditeur de composition VR"), + ("", "Help", "Aide"), + ("help.docs", "EffectCraft Help...", "Aide EffectCraft..."), + ("help.docs", "Scripting Help...", "Aide sur les scripts..."), + ("help.docs", "Expression Reference...", "Référence des expressions..."), + ("help.docs", "Effect Reference...", "Référence des effets..."), + ("anim.browsePresets", "Animation Presets...", "Programmes d’animation..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Raccourcis clavier..."), + ("help.inAppTutorials", "In-App Tutorials...", "Tutoriels dans l’application..."), + ("help.onlineTutorials", "Online Tutorials...", "Tutoriels en ligne..."), + ("help.systemReport", "System Compatibility Report...", "Rapport de compatibilité du système..."), + ("help.enableLogging", "Enable Logging", "Activer la journalisation"), + ("help.revealLogFile", "Reveal Logging File", "Révéler le fichier journal"), + ("help.discord", "Join the ArtCraft Discord...", "Rejoindre le Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Envoyer des commentaires..."), + ("help.website", "ArtCraft Website", "Site web ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Page d’accueil EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft sur GitHub"), + ("file.openDemoProject", "Open Demo Project", "Ouvrir le projet de démonstration"), +]; + +/// German (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const GERMAN: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Frame in die Zwischenablage kopieren"), + ("", "Front", "Vorderseite"), + ("", "Bevel", "Abschrägung"), + ("", "Side", "Seite"), + ("", "Back", "Rückseite"), + ("layer.addTextAnimator", "Color", "Farbe"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "Über EffectCraft..."), + ("", "Settings...", "Voreinstellungen..."), + ("app.settings", "General...", "Allgemein..."), + ("app.settings", "Startup & Repair...", "Start und Reparatur..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Komposition..."), + ("app.settings", "Previews...", "Vorschauen..."), + ("app.settings", "Appearance...", "Erscheinungsbild..."), + ("app.settings", "Grids & Guides...", "Raster und Hilfslinien..."), + ("app.settings", "Labels...", "Beschriftungen..."), + ("app.settings", "Type...", "Text..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Export..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Datenträger..."), + ("app.settings", "Memory & CPU...", "Speicher und CPU..."), + ("app.settings", "Video...", "Video..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skripting und Ausdrücke..."), + ("app.hide", "Hide EffectCraft", "EffectCraft ausblenden"), + ("app.hideOthers", "Hide Others", "Andere ausblenden"), + ("app.showAll", "Show All", "Alle einblenden"), + ("app.quit", "Quit EffectCraft", "EffectCraft beenden"), + ("", "File", "Datei"), + ("", "New", "Neu"), + ("file.newProject", "New Project", "Neues Projekt"), + ("file.newFromTemplate", "New Project from Template...", "Neues Projekt aus Vorlage..."), + ("project.newFolder", "New Folder", "Neuer Ordner"), + ("file.open", "Open Project...", "Projekt öffnen..."), + ("", "Open Recent", "Zuletzt verwendet öffnen"), + ("file.clearRecent", "Clear Recent Projects", "Zuletzt verwendete Projekte löschen"), + ("file.close", "Close", "Schließen"), + ("file.closeProject", "Close Project", "Projekt schließen"), + ("file.save", "Save", "Speichern"), + ("", "Save As", "Speichern unter"), + ("file.saveAs", "Save As...", "Speichern unter..."), + ("file.saveCopy", "Save a Copy...", "Kopie speichern..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Kopie als XML speichern..."), + ("templates.saveAs", "Save as Template...", "Als Vorlage speichern..."), + ("file.incrementAndSave", "Increment and Save", "Inkrementell speichern"), + ("file.revert", "Revert", "Wiederherstellen"), + ("", "Import", "Importieren"), + ("file.import", "File...", "Datei..."), + ("file.importMultiple", "Multiple Files...", "Mehrere Dateien..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), + ("file.importPlaceholder", "Placeholder...", "Platzhalter..."), + ("file.importSolid", "Solid...", "Farbfläche..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Fluchtpunkt (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Wesentliche-Grafiken-Vorlage..."), + ("", "Import Recent Footage", "Zuletzt verwendetes Footage importieren"), + ("file.clearRecentFootage", "Clear Recent Footage", "Zuletzt verwendetes Footage löschen"), + ("", "Export", "Exportieren"), + ("renderQueue.add", "Add to Render Queue", "An die Renderliste anfügen"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Wesentliche-Grafiken-Vorlage..."), + ("app.find", "Find", "Suchen"), + ("layer.addItem", "Add Footage to Comp", "Footage zur Komposition hinzufügen"), + ("file.newCompFromSelection", "New Comp from Selection...", "Neue Komposition aus Auswahl..."), + ("", "Dependencies", "Abhängigkeiten"), + ("file.collectFiles", "Collect Files...", "Dateien sammeln..."), + ("file.consolidateFootage", "Consolidate All Footage", "Gesamtes Footage konsolidieren"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Nicht verwendetes Footage entfernen"), + ("file.reduceProject", "Reduce Project", "Projekt reduzieren"), + ("file.findMissing", "Find Missing Effects", "Fehlende Effekte suchen"), + ("file.findMissing", "Find Missing Fonts", "Fehlende Schriftarten suchen"), + ("file.findMissing", "Find Missing Footage", "Fehlendes Footage suchen"), + ("file.relinkFootage", "Relink Missing Footage...", "Fehlendes Footage neu verknüpfen..."), + ("file.watchFolder", "Watch Folder...", "Überwachter Ordner..."), + ("", "Scripts", "Skripte"), + ("file.installScript", "Install Script File...", "Skriptdatei installieren..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."), + ("file.runScript", "Run Script File...", "Skriptdatei ausführen..."), + ("", "Create Proxy", "Proxy erstellen"), + ("file.createProxy", "Still...", "Standbild..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Proxy festlegen"), + ("file.setProxy", "File...", "Datei..."), + ("file.setProxyNone", "None", "Keine"), + ("", "Interpret Footage", "Footage interpretieren"), + ("file.interpretFootage", "Main...", "Haupteinstellungen..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Interpretation merken"), + ("file.applyInterpretation", "Apply Interpretation", "Interpretation anwenden"), + ("", "Replace Footage", "Footage ersetzen"), + ("file.replaceFootage", "File...", "Datei..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Platzhalter..."), + ("file.replaceWithSolid", "Solid...", "Farbfläche..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Durch Komposition mit Ebenen"), + ("file.reloadFootage", "Reload Footage", "Footage neu laden"), + ("file.revealInFinder", "Reveal in Finder", "Im Finder anzeigen"), + ("file.projectSettings", "Project Settings...", "Projekteinstellungen..."), + ("app.quit", "Exit", "Beenden"), + ("", "Edit", "Bearbeiten"), + ("edit.undo", "Undo", "Rückgängig"), + ("edit.redo", "Redo", "Wiederholen"), + ("", "History", "Verlauf"), + ("app.commandPalette", "Quick Apply...", "Schnellanwendung..."), + ("edit.cut", "Cut", "Ausschneiden"), + ("edit.copy", "Copy", "Kopieren"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Mit Eigenschaftslinks kopieren"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Mit relativen Eigenschaftslinks kopieren"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Nur Ausdruck kopieren"), + ("edit.paste", "Paste", "Einfügen"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Umgekehrte Keyframes einfügen"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Text mit übereinstimmender Formatierung einfügen"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Nur Textformatierung einfügen"), + ("edit.clear", "Clear", "Löschen"), + ("edit.duplicate", "Duplicate", "Duplizieren"), + ("edit.splitLayer", "Split Layer", "Ebene teilen"), + ("edit.liftWorkArea", "Lift Work Area", "Arbeitsbereich ausheben"), + ("edit.extractWorkArea", "Extract Work Area", "Arbeitsbereich extrahieren"), + ("edit.selectAll", "Select All", "Alles auswählen"), + ("edit.deselectAll", "Deselect All", "Alles abwählen"), + ("", "Label", "Beschriftung"), + ("edit.selectLabelGroup", "Select Label Group", "Beschriftungsgruppe auswählen"), + ("edit.label", "None", "Keine"), + ("edit.label", "Red", "Rot"), + ("edit.label", "Yellow", "Gelb"), + ("edit.label", "Aqua", "Aqua"), + ("edit.label", "Pink", "Rosa"), + ("edit.label", "Lavender", "Lavendel"), + ("edit.label", "Peach", "Pfirsich"), + ("edit.label", "Sea Foam", "Seegrün"), + ("edit.label", "Blue", "Blau"), + ("edit.label", "Green", "Grün"), + ("edit.label", "Purple", "Violett"), + ("edit.label", "Orange", "Orange"), + ("edit.label", "Brown", "Braun"), + ("edit.label", "Fuchsia", "Fuchsia"), + ("edit.label", "Cyan", "Cyan"), + ("edit.label", "Sandstone", "Sandstein"), + ("edit.label", "Dark Green", "Dunkelgrün"), + ("app.settings", "Edit Label Colors...", "Beschriftungsfarben bearbeiten..."), + ("", "Select Keyframe Label Group", "Keyframe-Beschriftungsgruppe auswählen"), + ("keys.selectLabelGroup", "On Selected Layers", "Auf ausgewählten Ebenen"), + ("keys.selectLabelGroup", "On All Layers", "Auf allen Ebenen"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Sichtbare Keyframes auf ausgewählten Ebenen"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Sichtbare Keyframes auf allen Ebenen"), + ("", "Purge", "Leeren"), + ("edit.purge", "All Cache...", "Gesamter Cache..."), + ("edit.purge", "All Memory & Disk Cache...", "Gesamter Speicher- und Datenträgercache..."), + ("edit.purge", "All Memory", "Gesamter Speicher"), + ("edit.purge", "All Disk Cache...", "Gesamter Datenträgercache..."), + ("edit.purge", "All 3D Cache...", "Gesamter 3D-Cache..."), + ("edit.purgeUndo", "Undo", "Rückgängig-Historie"), + ("edit.purge", "Image Cache Memory", "Bildcache-Speicher"), + ("edit.purge", "Snapshot", "Snapshot"), + ("edit.editOriginal", "Edit Original...", "Original bearbeiten..."), + ("", "Templates", "Vorlagen"), + ("app.templates", "Render Settings...", "Renderingeinstellungen..."), + ("app.templates", "Output Module...", "Ausgabemodul..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Tastaturbefehle"), + ("", "Preferences", "Voreinstellungen"), + ("", "Composition", "Komposition"), + ("comp.new", "New Composition...", "Neue Komposition..."), + ("comp.settings", "Composition Settings...", "Kompositionseinstellungen..."), + ("comp.setPosterTime", "Set Poster Time", "Posterzeit festlegen"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Komposition auf Arbeitsbereich zurechtschneiden"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Komposition auf Interessenbereich zuschneiden"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Komposition auf Grenzen der ausgewählten Ebene(n) zuschneiden"), + ("render.addOutputModule", "Add Output Module", "Ausgabemodul hinzufügen"), + ("", "Preview", "Vorschau"), + ("playback.toggle", "Play Current Preview", "Aktuelle Vorschau wiedergeben"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Frames im Leerlauf zwischenspeichern"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Frame speichern unter"), + ("comp.saveFrameAs", "File...", "Datei..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-Ebenen..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Vorrendern..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Aktuelle Vorschau speichern..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "In Wesentliche Grafiken öffnen"), + ("", "Responsive Design — Time", "Responsives Design – Zeit"), + ("comp.responsiveTime", "Create Intro", "Intro erstellen"), + ("comp.responsiveTime", "Create Outro", "Outro erstellen"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Geschützten Bereich aus Arbeitsbereich erstellen"), + ("comp.flowchart", "Composition Flowchart", "Kompositionsflussdiagramm"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Kompositions-Miniflussdiagramm"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "VR-Umgebung erstellen..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Cubemap extrahieren..."), + ("", "Layer", "Ebene"), + ("layer.newText", "Text", "Text"), + ("layer.newSolid", "Solid...", "Farbfläche..."), + ("layer.newLight", "Light...", "Licht..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Null-Objekt"), + ("layer.newShape", "Shape Layer", "Formebene"), + ("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."), + ("layer.new3dPrimitive", "Cube", "Würfel"), + ("layer.new3dPrimitive", "Sphere", "Kugel"), + ("layer.new3dPrimitive", "Plane", "Fläche"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Kegel"), + ("layer.new3dPrimitive", "Cylinder", "Zylinder"), + ("layer.settings", "Layer Settings...", "Ebeneneinstellungen..."), + ("layer.openLayer", "Open Layer", "Ebene öffnen"), + ("layer.openSource", "Open Layer Source", "Ebenenquelle öffnen"), + ("layer.revealInFinder", "Reveal in Finder", "Im Finder einblenden"), + ("", "Mask", "Maske"), + ("layer.addMask", "New Mask", "Neue Maske"), + ("layer.mask.shape", "Mask Shape...", "Maskenform..."), + ("layer.mask.set", "Mask Feather...", "Weiche Maskenkante..."), + ("layer.mask.set", "Mask Opacity...", "Maskendeckkraft..."), + ("layer.mask.set", "Mask Expansion...", "Maskenausweitung..."), + ("layer.mask.reset", "Reset Mask", "Maske zurücksetzen"), + ("layer.mask.remove", "Remove Mask", "Maske entfernen"), + ("layer.mask.removeAll", "Remove All Masks", "Alle Masken entfernen"), + ("track.mask", "Track Mask", "Maske tracken"), + ("", "Mode", "Modus"), + ("layer.mask.mode", "None", "Ohne"), + ("layer.mask.mode", "Add", "Addieren"), + ("layer.mask.mode", "Subtract", "Subtrahieren"), + ("layer.mask.mode", "Intersect", "Überschneiden"), + ("layer.mask.mode", "Lighten", "Aufhellen"), + ("layer.mask.mode", "Darken", "Abdunkeln"), + ("layer.mask.mode", "Difference", "Differenz"), + ("layer.mask.invert", "Inverted", "Umgekehrt"), + ("layer.mask.lock", "Locked", "Gesperrt"), + ("", "Motion Blur", "Bewegungsunschärfe"), + ("layer.mask.motionBlur", "Same As Layer", "Wie Ebene"), + ("layer.mask.motionBlur", "On", "Ein"), + ("layer.mask.motionBlur", "Off", "Aus"), + ("", "Feather Falloff", "Abfall der weichen Kante"), + ("layer.mask.featherFalloff", "Smooth", "Glätten"), + ("layer.mask.featherFalloff", "Linear", "Linear"), + ("layer.mask.unlockAll", "Unlock All Masks", "Alle Masken entsperren"), + ("layer.mask.lockOthers", "Lock Other Masks", "Andere Masken sperren"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Gesperrte Masken ausblenden"), + ("", "Mask and Shape Path", "Masken- und Formpfad"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Geschlossen"), + ("path.convertToBezier", "Convert To Bezier Path", "In Bézier-Pfad konvertieren"), + ("path.setFirstVertex", "Set First Vertex", "Ersten Scheitelpunkt festlegen"), + ("path.freeTransform", "Free Transform Points", "Punkte frei transformieren"), + ("", "Quality", "Qualität"), + ("layer.quality", "Best", "Beste"), + ("layer.quality", "Draft", "Entwurf"), + ("layer.quality", "Wireframe", "Drahtgitter"), + ("layer.sampling", "Bilinear", "Bilinear"), + ("layer.sampling", "Bicubic", "Bikubisch"), + ("", "Switches", "Schalter"), + ("layer.hideOtherVideo", "Hide Other Video", "Anderes Video ausblenden"), + ("layer.showAllVideo", "Show All Video", "Gesamtes Video einblenden"), + ("layer.unlockAll", "Unlock All Layers", "Alle Ebenen entsperren"), + ("layer.expressions", "Enable Expressions", "Expressions aktivieren"), + ("layer.expressions", "Disable Expressions", "Expressions deaktivieren"), + ("layer.setSwitch", "Shy", "Schüchtern"), + ("layer.setSwitch", "Lock", "Sperren"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Video"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Effekt"), + ("layer.setSwitch", "Collapse", "Transformierungen reduzieren"), + ("layer.setSwitch", "Motion Blur", "Bewegungsunschärfe"), + ("layer.setSwitch", "Adjustment Layer", "Einstellungsebene"), + ("", "Transform", "Transformieren"), + ("layer.transform", "Reset", "Zurücksetzen"), + ("layer.setTransform", "Anchor Point...", "Ankerpunkt..."), + ("layer.setTransform", "Position...", "Position..."), + ("layer.setTransform", "Scale...", "Skalierung..."), + ("layer.setTransform", "Orientation...", "Ausrichtung..."), + ("layer.setTransform", "Rotation...", "Drehung..."), + ("layer.setTransform", "Opacity...", "Deckkraft..."), + ("layer.transform", "Flip Horizontal", "Horizontal spiegeln"), + ("layer.transform", "Flip Vertical", "Vertikal spiegeln"), + ("layer.transform", "Center In View", "In Ansicht zentrieren"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Ankerpunkt im Ebeneninhalt zentrieren"), + ("layer.transform", "Fit to Comp", "An Komposition anpassen"), + ("layer.transform", "Fit to Comp Width", "An Kompositionsbreite anpassen"), + ("layer.transform", "Fit to Comp Height", "An Kompositionshöhe anpassen"), + ("layer.autoOrient", "Auto-Orient...", "Automatische Ausrichtung..."), + ("", "Time", "Zeit"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Zeitverzerrung aktivieren"), + ("layer.timeReverse", "Time-Reverse Layer", "Ebenenreihenfolge umkehren"), + ("layer.timeStretch", "Time Stretch...", "Zeitdehnung..."), + ("layer.freezeFrame", "Freeze Frame", "Frame fixieren"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Bei letztem Frame fixieren"), + ("layer.alignVideoToData", "Align Video to Data", "Video an Daten ausrichten"), + ("", "Frame Blending", "Frame-Überblendung"), + ("layer.frameBlending", "Off", "Aus"), + ("layer.frameBlending", "Frame Mix", "Frame-Mix"), + ("layer.frameBlending", "Pixel Motion", "Pixelbewegung"), + ("layer.setSwitch", "3D Layer", "3D-Ebene"), + ("layer.setSwitch", "Guide Layer", "Hilfsebene"), + ("layer.environment", "Environment Layer", "Umgebungsebene"), + ("", "Markers", "Marker"), + ("layer.addMarker", "Add Marker", "Marker hinzufügen"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Marker aus Quelle aktualisieren"), + ("layer.markersLock", "Lock Markers", "Marker sperren"), + ("layer.deleteAllMarkers", "Delete All Markers", "Alle Marker löschen"), + ("layer.setSwitch", "Preserve Transparency", "Transparenz beibehalten"), + ("", "Blending Mode", "Füllmethode"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Streuen"), + ("layer.setBlendMode", "Dancing Dissolve", "Sprenkeln mit Rauschen"), + ("layer.setBlendMode", "Darken", "Abdunkeln"), + ("layer.setBlendMode", "Multiply", "Multiplizieren"), + ("layer.setBlendMode", "Color Burn", "Farbig nachbelichten"), + ("layer.setBlendMode", "Classic Color Burn", "Farbig nachbelichten - klassisch"), + ("layer.setBlendMode", "Linear Burn", "Linear nachbelichten"), + ("layer.setBlendMode", "Darker Color", "Dunklere Farbe"), + ("layer.setBlendMode", "Add", "Addieren"), + ("layer.setBlendMode", "Lighten", "Aufhellen"), + ("layer.setBlendMode", "Screen", "Bildschirm"), + ("layer.setBlendMode", "Color Dodge", "Farbig abwedeln"), + ("layer.setBlendMode", "Classic Color Dodge", "Farbig abwedeln - klassisch"), + ("layer.setBlendMode", "Linear Dodge", "Linear abwedeln"), + ("layer.setBlendMode", "Lighter Color", "Hellere Farbe"), + ("layer.setBlendMode", "Overlay", "Überlagern"), + ("layer.setBlendMode", "Soft Light", "Weiches Licht"), + ("layer.setBlendMode", "Hard Light", "Hartes Licht"), + ("layer.setBlendMode", "Linear Light", "Lineares Licht"), + ("layer.setBlendMode", "Vivid Light", "Strahlendes Licht"), + ("layer.setBlendMode", "Pin Light", "Punktuelles Licht"), + ("layer.setBlendMode", "Hard Mix", "Harte Mischung"), + ("layer.setBlendMode", "Difference", "Differenz"), + ("layer.setBlendMode", "Classic Difference", "Differenz - klassisch"), + ("layer.setBlendMode", "Exclusion", "Ausschluss"), + ("layer.setBlendMode", "Subtract", "Subtrahieren"), + ("layer.setBlendMode", "Divide", "Dividieren"), + ("layer.setBlendMode", "Hue", "Farbton"), + ("layer.setBlendMode", "Saturation", "Sättigung"), + ("layer.setBlendMode", "Color", "Farbe"), + ("layer.setBlendMode", "Luminosity", "Luminanz"), + ("layer.setBlendMode", "Stencil Alpha", "Alphaschablone"), + ("layer.setBlendMode", "Stencil Luma", "Luminanzschablone"), + ("layer.setBlendMode", "Silhouette Alpha", "Alphasilhouette"), + ("layer.setBlendMode", "Silhouette Luma", "Luminanzsilhouette"), + ("layer.setBlendMode", "Alpha Add", "Alpha addieren"), + ("layer.setBlendMode", "Luminescent Premul", "Luminanz integrieren"), + ("layer.setBlendMode", "Next Blending Mode", "Nächste Füllmethode"), + ("layer.setBlendMode", "Previous Blending Mode", "Vorherige Füllmethode"), + ("", "Track Matte", "Spurmaske"), + ("layer.trackMatte", "No Track Matte", "Keine Spurmaske"), + ("layer.trackMatte", "Alpha Matte", "Alphamaske"), + ("layer.trackMatte", "Alpha Inverted Matte", "Umgekehrte Alphamaske"), + ("layer.trackMatte", "Luma Matte", "Luminanzmaske"), + ("layer.trackMatte", "Luma Inverted Matte", "Umgekehrte Luminanzmaske"), + ("layer.trackMatte", "Matte with Layer Above", "Spurmaske mit Ebene darüber"), + ("layer.trackMatte", "Matte with Layer Below", "Spurmaske mit Ebene darunter"), + ("", "Layer Styles", "Ebenenstile"), + ("layer.style.options", "Layer Style Options...", "Ebenenstiloptionen..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "In editierbare Formate umwandeln"), + ("layer.style.showAll", "Show All", "Alle anzeigen"), + ("layer.style.removeAll", "Remove All", "Alle entfernen"), + ("layer.style.dropShadow", "Drop Shadow", "Schlagschatten"), + ("layer.style.innerShadow", "Inner Shadow", "Schatten nach innen"), + ("layer.style.outerGlow", "Outer Glow", "Schein nach außen"), + ("layer.style.innerGlow", "Inner Glow", "Schein nach innen"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Abgeflachte Kante und Relief"), + ("layer.style.satin", "Satin", "Glanz"), + ("layer.style.colorOverlay", "Color Overlay", "Farbüberlagerung"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Verlaufsüberlagerung"), + ("layer.style.stroke", "Stroke", "Strich"), + ("path.groupShapes", "Group Shapes", "Formen gruppieren"), + ("path.ungroupShapes", "Ungroup Shapes", "Gruppierung von Formen aufheben"), + ("", "Arrange", "Anordnen"), + ("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorn"), + ("layer.arrange", "Bring Layer Forward", "Ebene nach vorne"), + ("layer.arrange", "Send Layer Backward", "Ebene nach hinten"), + ("layer.arrange", "Send Layer to Back", "Ebene ganz nach hinten"), + ("", "Reveal", "Anzeigen"), + ("layer.revealSource", "Reveal Layer Source in Project", "Ebenenquelle im Projekt anzeigen"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Ebene im Projekt-Flussdiagramm anzeigen"), + ("comp.revealInProject", "Reveal Composition in Project", "Komposition im Projekt anzeigen"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Ausdrucksfehler anzeigen"), + ("", "Create", "Erstellen"), + ("layer.create", "Convert to Editable Text", "In editierbaren Text umwandeln"), + ("layer.create", "Create Shapes from Text", "Formen aus Text erstellen"), + ("layer.create", "Create Masks from Text", "Masken aus Text erstellen"), + ("layer.create", "Create Shapes from Vector Layer", "Formen aus Vektorebene erstellen"), + ("layer.create", "Create Keyframes from Data", "Keyframes aus Daten erstellen"), + ("layer.create", "Null Controllers for Positional Points", "Null-Steuerungen für Positionspunkte"), + ("layer.create", "Null Controllers for Path Points", "Null-Steuerungen für Pfadpunkte"), + ("layer.create", "Nulls Following Path Points", "Nullebenen, die Pfadpunkten folgen"), + ("layer.create", "Null Tracing Along Path", "Nullebene, die dem Pfad folgt"), + ("layer.create", "Create 3D Layer Instance", "3D-Ebeneninstanz erstellen"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Kamera aus 3D-Ansicht erstellen"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Stereo-3D-Rig erstellen"), + ("camera.orbitNull", "Create Orbit Null", "Orbit-Nullebene erstellen"), + ("camera.fromModel", "Create Cameras from 3D Model", "Kameras aus 3D-Modell erstellen"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Fokusdistanz an Point of Interest binden"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Fokusdistanz an Ebene binden"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Fokusdistanz auf Ebene festlegen"), + ("layer.cameraSettings", "Camera Settings...", "Kameraeinstellungen..."), + ("view.reset3DView", "Reset 3D View", "3D-Ansicht zurücksetzen"), + ("", "Light", "Licht"), + ("light.fromModel", "Create Lights from 3D Model", "Lichter aus 3D-Modell erstellen"), + ("light.controlWithCamera", "Control Light with Camera", "Licht mit Kamera steuern"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Hintergrundebene für Umgebungslicht erstellen"), + ("", "Material", "Material"), + ("material.revealSource", "Reveal Material Source in Project", "Materialquelle im Projekt anzeigen"), + ("material.reset", "Reset Material", "Material zurücksetzen"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Material duplizieren und zuweisen"), + ("layer.autoTrace", "Auto-trace...", "Automatisch nachzeichnen..."), + ("layer.precompose", "Pre-compose...", "Vorkomponieren..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Szenenbearbeitungs-Erkennung..."), + ("", "Effect", "Effekt"), + ("window.panel", "Effect Controls", "Effektsteuerungen"), + ("effect.applyLast", "Last Effect", "Letzter Effekt"), + ("effect.removeAll", "Remove All", "Alle entfernen"), + ("effect.manage", "Manage Effects...", "Effekte verwalten..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Effekt-Plug-in laden..."), + ("", "Animation", "Animation"), + ("anim.savePreset", "Save Animation Preset...", "Animationsvorgabe speichern..."), + ("anim.applyPreset", "Apply Animation Preset...", "Animationsvorgabe anwenden..."), + ("", "Recent Animation Presets", "Zuletzt verwendete Animationsvorgaben"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Zuletzt verwendete Vorgaben löschen"), + ("anim.browsePresets", "Browse Presets...", "Vorgaben durchsuchen..."), + ("", "Text Animation Presets", "Textanimationsvorgaben"), + ("layer.applyTextPreset", "Typewriter", "Schreibmaschine"), + ("layer.applyTextPreset", "Fade Up Characters", "Zeichen einblenden"), + ("layer.applyTextPreset", "Bounce In Words", "Wörter springen ein"), + ("layer.applyTextPreset", "Tracking In", "Laufweite verringern"), + ("layer.applyTextPreset", "Scramble", "Verwürfeln"), + ("layer.applyTextPreset", "Blur In", "Aus Unschärfe einblenden"), + ("layer.applyTextPreset", "Jitter", "Zittern"), + ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), + ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), + ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), + ("", "Keyframe Assistant", "Keyframe-Assistent"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Audio in Keyframes konvertieren"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Ausdruck in Keyframes konvertieren"), + ("keys.easyEase", "Easy Ease", "Easy Ease"), + ("keys.easyEaseIn", "Easy Ease In", "Easy Ease In"), + ("keys.easyEaseOut", "Easy Ease Out", "Easy Ease Out"), + ("keys.exponentialScale", "Exponential Scale", "Exponentielle Skalierung"), + ("keys.rpfCameraImport", "RPF Camera Import", "RPF-Kameraimport"), + ("layer.sequence", "Sequence Layers...", "Ebenen sequenzieren..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Keyframes zeitlich umkehren"), + ("", "Animate Text", "Text animieren"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "3D pro Zeichen aktivieren"), + ("layer.addTextAnimator", "Anchor Point", "Ankerpunkt"), + ("layer.addTextAnimator", "Position", "Position"), + ("layer.addTextAnimator", "Scale", "Skalierung"), + ("layer.addTextAnimator", "Skew", "Neigung"), + ("layer.addTextAnimator", "Rotation", "Drehung"), + ("layer.addTextAnimator", "Opacity", "Deckkraft"), + ("layer.addTextAnimator", "All Transform Properties", "Alle Transformationseigenschaften"), + ("", "Fill Color", "Füllfarbe"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Farbton"), + ("layer.addTextAnimator", "Saturation", "Sättigung"), + ("layer.addTextAnimator", "Brightness", "Helligkeit"), + ("", "Stroke Color", "Strichfarbe"), + ("layer.addTextAnimator", "Stroke Width", "Strichstärke"), + ("layer.addTextAnimator", "Tracking", "Laufweite"), + ("layer.addTextAnimator", "Line Anchor", "Zeilenanker"), + ("layer.addTextAnimator", "Line Spacing", "Zeilenabstand"), + ("layer.addTextAnimator", "Character Offset", "Zeichenversatz"), + ("layer.addTextAnimator", "Character Value", "Zeichenwert"), + ("layer.addTextAnimator", "Blur", "Unschärfe"), + ("text.animatorFontAxes", "Variable Font Axes", "Achsen der variablen Schriftart"), + ("", "Add Text Selector", "Textselektor hinzufügen"), + ("text.addSelector", "Range", "Bereich"), + ("text.addSelector", "Wiggly", "Wackeln"), + ("text.addSelector", "Expression", "Ausdruck"), + ("text.removeAllAnimators", "Remove All Text Animators", "Alle Text-Animatoren entfernen"), + ("prop.setExpression", "Add Expression", "Ausdruck hinzufügen"), + ("essential.addProperty", "Add Property to Essential Graphics", "Eigenschaft zu Essential-Grafik hinzufügen"), + ("prop.separateDimensions", "Separate Dimensions", "Dimensionen trennen"), + ("track.camera", "Track Camera", "Kamera verfolgen"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp-Stabilisator VFX"), + ("track.motion", "Track Motion", "Bewegung verfolgen"), + ("track.stabilize", "Stabilize Motion", "Bewegung stabilisieren"), + ("track.property", "Track this Property", "Diese Eigenschaft verfolgen"), + ("anim.reveal", "Reveal Properties with Keyframes", "Eigenschaften mit Keyframes anzeigen"), + ("anim.reveal", "Reveal Properties with Animation", "Eigenschaften mit Animation anzeigen"), + ("anim.reveal", "Reveal All Modified Properties", "Alle geänderten Eigenschaften anzeigen"), + ("", "View", "Ansicht"), + ("view.newViewer", "New Viewer", "Neuer Viewer"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Mit neuem gesperrten Viewer teilen"), + ("view.zoomIn", "Zoom In", "Vergrößern"), + ("view.zoomOut", "Zoom Out", "Verkleinern"), + ("", "Resolution", "Auflösung"), + ("view.res.full", "Full", "Voll"), + ("view.res.half", "Half", "Halb"), + ("view.res.third", "Third", "Drittel"), + ("view.res.quarter", "Quarter", "Viertel"), + ("view.res.custom", "Custom...", "Benutzerdefiniert..."), + ("view.displayColorManagement", "Use Display Color Management", "Farbverwaltung des Displays verwenden"), + ("", "Simulate Output", "Ausgabe simulieren"), + ("view.simulateOutput", "No Output Simulation", "Keine Ausgabesimulation"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Legacy-Macintosh-RGB (Gamma 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Internet-Standard-RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Linear (Gamma 1.0)"), + ("view.customRgb", "My Custom RGB...", "Mein eigenes RGB..."), + ("view.simulateOutput", "Custom...", "Benutzerdefiniert..."), + ("view.rulers", "Show Rulers", "Lineale anzeigen"), + ("", "Panel Background Color", "Hintergrundfarbe des Bedienfelds"), + ("view.panelBackground", "Black", "Schwarz"), + ("view.panelBackground", "Dark Gray", "Dunkelgrau"), + ("view.panelBackground", "Medium Gray (Default)", "Mittelgrau (Standard)"), + ("view.panelBackground", "Light Gray", "Hellgrau"), + ("view.panelBackground", "White", "Weiß"), + ("view.panelBackground", "Custom", "Benutzerdefiniert"), + ("view.panelBackground", "Select Custom Background Color...", "Benutzerdefinierte Hintergrundfarbe auswählen..."), + ("view.guides", "Show Guides", "Hilfslinien anzeigen"), + ("view.snapToGuides", "Snap to Guides", "An Hilfslinien einrasten"), + ("view.lockGuides", "Lock Guides", "Hilfslinien sperren"), + ("view.addGuide", "Add Guide...", "Hilfslinie hinzufügen..."), + ("view.clearGuides", "Clear Guides", "Hilfslinien entfernen"), + ("view.importGuides", "Import Guides...", "Hilfslinien importieren..."), + ("view.exportGuides", "Export Guides...", "Hilfslinien exportieren..."), + ("view.grid", "Show Grid", "Raster anzeigen"), + ("view.snapToGrid", "Snap to Grid", "Am Raster einrasten"), + ("view.snapping", "Snapping", "Einrasten"), + ("view.options", "View Options...", "Ansichtsoptionen..."), + ("view.layerControls", "Show Layer Controls", "Ebenensteuerelemente anzeigen"), + ("", "Switch View Layout", "Ansichtslayout wechseln"), + ("view.layout", "1 View", "1 Ansicht"), + ("view.layout", "2 Views", "2 Ansichten"), + ("view.layout", "4 Views", "4 Ansichten"), + ("view.shareViewOptions", "Share View Options", "Ansichtsoptionen teilen"), + ("", "Switch 3D View", "3D-Ansicht wechseln"), + ("view.3d.activeCamera", "Active Camera", "Aktive Kamera"), + ("view.3d.default", "Default", "Standard"), + ("view.3d.front", "Front", "Vorne"), + ("view.3d.left", "Left", "Links"), + ("view.3d.top", "Top", "Oben"), + ("view.3d.back", "Back", "Hinten"), + ("view.3d.right", "Right", "Rechts"), + ("view.3d.bottom", "Bottom", "Unten"), + ("view.3d.custom1", "Custom View 1", "Benutzeransicht 1"), + ("view.3d.custom2", "Custom View 2", "Benutzeransicht 2"), + ("view.3d.custom3", "Custom View 3", "Benutzeransicht 3"), + ("", "Assign Shortcut to 3D View", "Tastenkürzel für 3D-Ansicht zuweisen"), + ("view.3d.last", "Switch to Last 3D View", "Zur letzten 3D-Ansicht wechseln"), + ("view.lookAtSelected", "Look at Selected Layers", "Ausgewählte Ebenen fokussieren"), + ("view.lookAtAll", "Look at All Layers", "Alle Ebenen fokussieren"), + ("time.set", "Go to Time...", "Gehe zu Zeit..."), + ("view.fullScreen", "Enter Full Screen", "Vollbildmodus aktivieren"), + ("", "Window", "Fenster"), + ("", "Workspace", "Arbeitsfläche"), + ("window.workspace", "Default", "Standard"), + ("window.workspace", "Review", "Überprüfen"), + ("window.workspace", "Learn", "Lernen"), + ("window.workspace", "Small Screen", "Kleiner Bildschirm"), + ("window.workspace", "Standard", "Standard"), + ("window.workspace", "All Panels", "Alle Bedienfelder"), + ("window.workspace", "Animation", "Animation"), + ("window.workspace", "Color", "Farbe"), + ("window.workspace", "Effects", "Effekte"), + ("window.workspace", "Essential Graphics", "Essential-Grafik"), + ("window.workspace", "Minimal", "Minimal"), + ("window.workspace", "Motion Tracking", "Bewegungsverfolgung"), + ("window.workspace", "Paint", "Malen"), + ("window.workspace", "Text", "Text"), + ("window.workspace", "Undocked Panels", "Abgedockte Bedienfelder"), + ("window.resetWorkspace", "Reset to Saved Layout", "Auf gespeichertes Layout zurücksetzen"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Änderungen an dieser Arbeitsfläche speichern"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Als neue Arbeitsfläche speichern..."), + ("window.editWorkspaces", "Edit Workspaces...", "Arbeitsflächen bearbeiten..."), + ("", "Assign Shortcut to Workspace", "Tastenkürzel für Arbeitsfläche zuweisen"), + ("window.panel", "Align", "Ausrichten"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pinsel"), + ("window.panel", "Character", "Zeichen"), + ("window.panel", "Content-Aware Fill", "Inhaltsbasiertes Ausfüllen"), + ("window.panel", "Effects & Presets", "Effekte und Presets"), + ("window.panel", "Essential Graphics", "Essential-Grafik"), + ("window.panel", "Info", "Info"), + ("help.inAppTutorials", "Learn", "Lernen"), + ("window.panel", "Lumetri Scopes", "Lumetri-Scopes"), + ("window.panel", "Mask Interpolation", "Maskeninterpolation"), + ("window.panel", "Media Browser", "Medienbrowser"), + ("window.panel", "Metadata", "Metadaten"), + ("window.panel", "Motion Sketch", "Bewegungsskizze"), + ("window.panel", "Paint", "Malen"), + ("window.panel", "Paragraph", "Absatz"), + ("window.panel", "Preview", "Vorschau"), + ("window.panel", "Progress", "Fortschritt"), + ("window.panel", "Properties", "Eigenschaften"), + ("window.panel", "Script Console", "Skriptkonsole"), + ("window.panel", "Smoother", "Smoother"), + ("window.panel", "Tools", "Werkzeuge"), + ("window.panel", "Tracker", "Tracker"), + ("window.panel", "Wiggler", "Wiggler"), + ("window.panel", "Composition", "Komposition"), + ("window.panel", "Flowchart", "Flussdiagramm"), + ("window.panel", "Footage", "Footage"), + ("window.panel", "Layer", "Ebene"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Render-Warteschlange"), + ("window.panel", "Timeline", "Zeitleiste"), + ("window.panel", "Create Nulls From Paths", "Null-Objekte aus Pfaden erstellen"), + ("window.panel", "VR Comp Editor", "VR-Kompositionseditor"), + ("", "Help", "Hilfe"), + ("help.docs", "EffectCraft Help...", "EffectCraft-Hilfe..."), + ("help.docs", "Scripting Help...", "Hilfe zu Skripts..."), + ("help.docs", "Expression Reference...", "Ausdrucksreferenz..."), + ("help.docs", "Effect Reference...", "Effektreferenz..."), + ("anim.browsePresets", "Animation Presets...", "Animations-Presets..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Tastenkürzel..."), + ("help.inAppTutorials", "In-App Tutorials...", "In-App-Tutorials..."), + ("help.onlineTutorials", "Online Tutorials...", "Online-Tutorials..."), + ("help.systemReport", "System Compatibility Report...", "Bericht zur Systemkompatibilität..."), + ("help.enableLogging", "Enable Logging", "Protokollierung aktivieren"), + ("help.revealLogFile", "Reveal Logging File", "Protokolldatei anzeigen"), + ("help.discord", "Join the ArtCraft Discord...", "ArtCraft-Discord beitreten..."), + ("help.reportIssue", "Provide Feedback...", "Feedback senden..."), + ("help.website", "ArtCraft Website", "ArtCraft-Website"), + ("help.appPage", "EffectCraft Home Page", "EffectCraft-Startseite"), + ("help.github", "EffectCraft on GitHub", "EffectCraft auf GitHub"), + ("file.openDemoProject", "Open Demo Project", "Demoprojekt öffnen"), +]; + +// <<< generated i18n catalogs + +/// Czech (cs): original phrase translations of every fixed menu entry. +const CZECH: &[(&str, &str, &str)] = &[ + ("", "Front", "Přední strana"), + ("", "Bevel", "Zkosení"), + ("", "Side", "Boční strana"), + ("", "Back", "Zadní strana"), + ("layer.addTextAnimator", "Color", "Barva"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O aplikaci EffectCraft..."), + ("", "Settings...", "Nastavení..."), + ("app.settings", "General...", "Obecné..."), + ("app.settings", "Startup & Repair...", "Spuštění a oprava..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozice..."), + ("app.settings", "Previews...", "Náhledy..."), + ("app.settings", "Appearance...", "Vzhled..."), + ("app.settings", "Grids & Guides...", "Mřížky a vodítka..."), + ("app.settings", "Labels...", "Štítky..."), + ("app.settings", "Type...", "Text..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Export..."), + ("app.settings", "Audio...", "Zvuk..."), + ("app.settings", "Disk...", "Disk..."), + ("app.settings", "Memory & CPU...", "Paměť a CPU..."), + ("app.settings", "Video...", "Video..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skripty a výrazy..."), + ("app.hide", "Hide EffectCraft", "Skrýt EffectCraft"), + ("app.hideOthers", "Hide Others", "Skrýt ostatní"), + ("app.showAll", "Show All", "Zobrazit vše"), + ("app.quit", "Quit EffectCraft", "Ukončit EffectCraft"), + ("", "File", "Soubor"), + ("", "New", "Nový"), + ("file.newProject", "New Project", "Nový projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nový projekt ze šablony..."), + ("project.newFolder", "New Folder", "Nová složka"), + ("file.open", "Open Project...", "Otevřít projekt..."), + ("", "Open Recent", "Otevřít nedávné"), + ("file.clearRecent", "Clear Recent Projects", "Vymazat nedávné projekty"), + ("file.close", "Close", "Zavřít"), + ("file.closeProject", "Close Project", "Zavřít projekt"), + ("file.save", "Save", "Uložit"), + ("", "Save As", "Uložit jako"), + ("file.saveAs", "Save As...", "Uložit jako..."), + ("file.saveCopy", "Save a Copy...", "Uložit kopii..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Uložit kopii jako XML..."), + ("templates.saveAs", "Save as Template...", "Uložit jako šablonu..."), + ("file.incrementAndSave", "Increment and Save", "Zvýšit číslo verze a uložit"), + ("file.revert", "Revert", "Vrátit k uložené verzi"), + ("", "Import", "Importovat"), + ("file.import", "File...", "Soubor..."), + ("file.importMultiple", "Multiple Files...", "Více souborů..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."), + ("file.importSolid", "Solid...", "Plná vrstva..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Úběžný bod (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Šablona základní grafiky..."), + ("", "Import Recent Footage", "Importovat nedávný záznam"), + ("file.clearRecentFootage", "Clear Recent Footage", "Vymazat nedávné záznamy"), + ("", "Export", "Exportovat"), + ("renderQueue.add", "Add to Render Queue", "Přidat do fronty vykreslování"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Šablona základní grafiky..."), + ("app.find", "Find", "Najít"), + ("layer.addItem", "Add Footage to Comp", "Přidat záznam do kompozice"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nová kompozice z výběru..."), + ("", "Dependencies", "Závislosti"), + ("file.collectFiles", "Collect Files...", "Shromáždit soubory..."), + ("file.consolidateFootage", "Consolidate All Footage", "Sloučit duplicitní záznamy"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Odstranit nepoužité záznamy"), + ("file.reduceProject", "Reduce Project", "Zredukovat projekt"), + ("file.findMissing", "Find Missing Effects", "Najít chybějící efekty"), + ("file.findMissing", "Find Missing Fonts", "Najít chybějící písma"), + ("file.findMissing", "Find Missing Footage", "Najít chybějící záznamy"), + ("file.relinkFootage", "Relink Missing Footage...", "Znovu připojit chybějící záznamy..."), + ("file.watchFolder", "Watch Folder...", "Sledovat složku..."), + ("", "Scripts", "Skripty"), + ("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Spustit soubor skriptu..."), + ("", "Create Proxy", "Vytvořit zástupný soubor"), + ("file.createProxy", "Still...", "Statický snímek..."), + ("file.createProxy", "Movie...", "Video..."), + ("", "Set Proxy", "Nastavit zástupný soubor"), + ("file.setProxy", "File...", "Soubor..."), + ("file.setProxyNone", "None", "Žádný"), + ("", "Interpret Footage", "Interpretovat záznam"), + ("file.interpretFootage", "Main...", "Hlavní..."), + ("file.interpretProxy", "Proxy...", "Zástupný..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamatovat interpretaci"), + ("file.applyInterpretation", "Apply Interpretation", "Použít interpretaci"), + ("", "Replace Footage", "Nahradit záznam"), + ("file.replaceFootage", "File...", "Soubor..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Zástupný záznam..."), + ("file.replaceWithSolid", "Solid...", "Plná vrstva..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Kompozicí s vrstvami"), + ("file.reloadFootage", "Reload Footage", "Znovu načíst záznam"), + ("file.revealInFinder", "Reveal in Finder", "Zobrazit ve Finderu"), + ("file.projectSettings", "Project Settings...", "Nastavení projektu..."), + ("app.quit", "Exit", "Ukončit"), + ("", "Edit", "Úpravy"), + ("edit.undo", "Undo", "Zpět"), + ("edit.redo", "Redo", "Znovu"), + ("", "History", "Historie"), + ("app.commandPalette", "Quick Apply...", "Rychle použít..."), + ("edit.cut", "Cut", "Vyjmout"), + ("edit.copy", "Copy", "Kopírovat"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopírovat s odkazy na vlastnosti"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopírovat s relativními odkazy na vlastnosti"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopírovat pouze výraz"), + ("edit.paste", "Paste", "Vložit"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Vložit klíčové snímky v opačném pořadí"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Vložit text a přizpůsobit formátování"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Vložit pouze formátování textu"), + ("edit.clear", "Clear", "Vymazat"), + ("edit.duplicate", "Duplicate", "Duplikovat"), + ("edit.splitLayer", "Split Layer", "Rozdělit vrstvu"), + ("edit.liftWorkArea", "Lift Work Area", "Vyjmout pracovní oblast bez posunu"), + ("edit.extractWorkArea", "Extract Work Area", "Vyjmout pracovní oblast s posunem"), + ("edit.selectAll", "Select All", "Vybrat vše"), + ("edit.deselectAll", "Deselect All", "Zrušit výběr"), + ("", "Label", "Štítek"), + ("edit.selectLabelGroup", "Select Label Group", "Vybrat skupinu podle štítku"), + ("edit.label", "None", "Žádný"), + ("edit.label", "Red", "Červený"), + ("edit.label", "Yellow", "Žlutý"), + ("edit.label", "Aqua", "Akvamarínový"), + ("edit.label", "Pink", "Růžový"), + ("edit.label", "Lavender", "Levandulový"), + ("edit.label", "Peach", "Broskvový"), + ("edit.label", "Sea Foam", "Mořská pěna"), + ("edit.label", "Blue", "Modrý"), + ("edit.label", "Green", "Zelený"), + ("edit.label", "Purple", "Fialový"), + ("edit.label", "Orange", "Oranžový"), + ("edit.label", "Brown", "Hnědý"), + ("edit.label", "Fuchsia", "Fuchsiový"), + ("edit.label", "Cyan", "Azurový"), + ("edit.label", "Sandstone", "Pískovcový"), + ("edit.label", "Dark Green", "Tmavě zelený"), + ("app.settings", "Edit Label Colors...", "Upravit barvy štítků..."), + ("", "Select Keyframe Label Group", "Vybrat skupinu podle štítku klíčových snímků"), + ("keys.selectLabelGroup", "On Selected Layers", "Na vybraných vrstvách"), + ("keys.selectLabelGroup", "On All Layers", "Na všech vrstvách"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Viditelné klíčové snímky na vybraných vrstvách"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Viditelné klíčové snímky na všech vrstvách"), + ("", "Purge", "Vyprázdnit"), + ("edit.purge", "All Cache...", "Celou mezipaměť..."), + ("edit.purge", "All Memory & Disk Cache...", "Celou paměťovou a diskovou mezipaměť..."), + ("edit.purge", "All Memory", "Veškerou paměť"), + ("edit.purge", "All Disk Cache...", "Celou diskovou mezipaměť..."), + ("edit.purge", "All 3D Cache...", "Celou 3D mezipaměť..."), + ("edit.purgeUndo", "Undo", "Historii vracení změn"), + ("edit.purge", "Image Cache Memory", "Paměť obrazové mezipaměti"), + ("edit.purge", "Snapshot", "Snímek náhledu"), + ("edit.editOriginal", "Edit Original...", "Upravit originál..."), + ("", "Templates", "Šablony"), + ("app.templates", "Render Settings...", "Nastavení vykreslování..."), + ("app.templates", "Output Module...", "Výstupní modul..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Klávesové zkratky"), + ("", "Preferences", "Předvolby"), + ("", "Composition", "Kompozice"), + ("comp.new", "New Composition...", "Nová kompozice..."), + ("comp.settings", "Composition Settings...", "Nastavení kompozice..."), + ("comp.setPosterTime", "Set Poster Time", "Nastavit čas reprezentativního snímku"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Oříznout kompozici na pracovní oblast"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Oříznout kompozici na oblast zájmu"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Oříznout kompozici na hranice vybrané vrstvy (vrstev)"), + ("render.addOutputModule", "Add Output Module", "Přidat výstupní modul"), + ("", "Preview", "Náhled"), + ("playback.toggle", "Play Current Preview", "Přehrát aktuální náhled"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Ukládat snímky do mezipaměti při nečinnosti"), + ("playback.audio", "Audio", "Zvuk"), + ("", "Save Frame As", "Uložit snímek jako"), + ("comp.saveFrameAs", "File...", "Soubor..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Vrstvy Photoshopu..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopírovat snímek do schránky"), + ("render.preRender", "Pre-render...", "Předvykreslit..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Uložit aktuální náhled..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otevřít v základní grafice"), + ("", "Responsive Design — Time", "Responzivní návrh — čas"), + ("comp.responsiveTime", "Create Intro", "Vytvořit úvod"), + ("comp.responsiveTime", "Create Outro", "Vytvořit závěr"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Vytvořit chráněnou oblast z pracovní oblasti"), + ("comp.flowchart", "Composition Flowchart", "Vývojový diagram kompozice"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Malý vývojový diagram kompozice"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Vytvořit prostředí VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extrahovat krychlovou mapu..."), + ("", "Layer", "Vrstva"), + ("layer.newText", "Text", "Text"), + ("layer.newSolid", "Solid...", "Plná vrstva..."), + ("layer.newLight", "Light...", "Světlo..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Prázdný objekt"), + ("layer.newShape", "Shape Layer", "Vrstva tvaru"), + ("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."), + ("layer.new3dPrimitive", "Cube", "Krychle"), + ("layer.new3dPrimitive", "Sphere", "Koule"), + ("layer.new3dPrimitive", "Plane", "Rovina"), + ("layer.new3dPrimitive", "Torus", "Prstenec"), + ("layer.new3dPrimitive", "Cone", "Kužel"), + ("layer.new3dPrimitive", "Cylinder", "Válec"), + ("layer.settings", "Layer Settings...", "Nastavení vrstvy..."), + ("layer.openLayer", "Open Layer", "Otevřít vrstvu"), + ("layer.openSource", "Open Layer Source", "Otevřít zdroj vrstvy"), + ("layer.revealInFinder", "Reveal in Finder", "Zobrazit ve Finderu"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nová maska"), + ("layer.mask.shape", "Mask Shape...", "Tvar masky..."), + ("layer.mask.set", "Mask Feather...", "Prolnutí okrajů masky..."), + ("layer.mask.set", "Mask Opacity...", "Krytí masky..."), + ("layer.mask.set", "Mask Expansion...", "Rozšíření masky..."), + ("layer.mask.reset", "Reset Mask", "Obnovit masku"), + ("layer.mask.remove", "Remove Mask", "Odstranit masku"), + ("layer.mask.removeAll", "Remove All Masks", "Odstranit všechny masky"), + ("track.mask", "Track Mask", "Sledovat masku"), + ("", "Mode", "Režim"), + ("layer.mask.mode", "None", "Žádný"), + ("layer.mask.mode", "Add", "Přičíst"), + ("layer.mask.mode", "Subtract", "Odečíst"), + ("layer.mask.mode", "Intersect", "Průnik"), + ("layer.mask.mode", "Lighten", "Zesvětlit"), + ("layer.mask.mode", "Darken", "Ztmavit"), + ("layer.mask.mode", "Difference", "Rozdíl"), + ("layer.mask.invert", "Inverted", "Invertovaná"), + ("layer.mask.lock", "Locked", "Zamknutá"), + ("", "Motion Blur", "Rozostření pohybem"), + ("layer.mask.motionBlur", "Same As Layer", "Stejné jako u vrstvy"), + ("layer.mask.motionBlur", "On", "Zapnuto"), + ("layer.mask.motionBlur", "Off", "Vypnuto"), + ("", "Feather Falloff", "Průběh prolnutí okrajů"), + ("layer.mask.featherFalloff", "Smooth", "Plynulý"), + ("layer.mask.featherFalloff", "Linear", "Lineární"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odemknout všechny masky"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zamknout ostatní masky"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Skrýt zamknuté masky"), + ("", "Mask and Shape Path", "Cesta masky a tvaru"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Uzavřená"), + ("path.convertToBezier", "Convert To Bezier Path", "Převést na Bézierovu cestu"), + ("path.setFirstVertex", "Set First Vertex", "Nastavit první vrchol"), + ("path.freeTransform", "Free Transform Points", "Volně transformovat body"), + ("", "Quality", "Kvalita"), + ("layer.quality", "Best", "Nejlepší"), + ("layer.quality", "Draft", "Pracovní"), + ("layer.quality", "Wireframe", "Drátový model"), + ("layer.sampling", "Bilinear", "Bilineární"), + ("layer.sampling", "Bicubic", "Bikubické"), + ("", "Switches", "Přepínače"), + ("layer.hideOtherVideo", "Hide Other Video", "Skrýt ostatní video"), + ("layer.showAllVideo", "Show All Video", "Zobrazit veškeré video"), + ("layer.unlockAll", "Unlock All Layers", "Odemknout všechny vrstvy"), + ("layer.expressions", "Enable Expressions", "Povolit výrazy"), + ("layer.expressions", "Disable Expressions", "Zakázat výrazy"), + ("layer.setSwitch", "Shy", "Skrýt z časové osy"), + ("layer.setSwitch", "Lock", "Zamknout"), + ("layer.setSwitch", "Audio", "Zvuk"), + ("layer.setSwitch", "Video", "Video"), + ("layer.setSwitch", "Solo", "Sólo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Sbalit transformace"), + ("layer.setSwitch", "Motion Blur", "Rozostření pohybem"), + ("layer.setSwitch", "Adjustment Layer", "Vrstva úprav"), + ("", "Transform", "Transformace"), + ("layer.transform", "Reset", "Obnovit"), + ("layer.setTransform", "Anchor Point...", "Kotevní bod..."), + ("layer.setTransform", "Position...", "Pozice..."), + ("layer.setTransform", "Scale...", "Měřítko..."), + ("layer.setTransform", "Orientation...", "Orientace..."), + ("layer.setTransform", "Rotation...", "Otočení..."), + ("layer.setTransform", "Opacity...", "Krytí..."), + ("layer.transform", "Flip Horizontal", "Převrátit vodorovně"), + ("layer.transform", "Flip Vertical", "Převrátit svisle"), + ("layer.transform", "Center In View", "Vystředit v zobrazení"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Vystředit kotevní bod v obsahu vrstvy"), + ("layer.transform", "Fit to Comp", "Přizpůsobit kompozici"), + ("layer.transform", "Fit to Comp Width", "Přizpůsobit šířce kompozice"), + ("layer.transform", "Fit to Comp Height", "Přizpůsobit výšce kompozice"), + ("layer.autoOrient", "Auto-Orient...", "Automaticky orientovat..."), + ("", "Time", "Čas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Povolit přemapování času"), + ("layer.timeReverse", "Time-Reverse Layer", "Obrátit čas vrstvy"), + ("layer.timeStretch", "Time Stretch...", "Roztažení času..."), + ("layer.freezeFrame", "Freeze Frame", "Zmrazit snímek"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zmrazit na posledním snímku"), + ("layer.alignVideoToData", "Align Video to Data", "Zarovnat video podle dat"), + ("", "Frame Blending", "Prolnutí snímků"), + ("layer.frameBlending", "Off", "Vypnuto"), + ("layer.frameBlending", "Frame Mix", "Smíchání snímků"), + ("layer.frameBlending", "Pixel Motion", "Pohyb pixelů"), + ("layer.setSwitch", "3D Layer", "3D vrstva"), + ("layer.setSwitch", "Guide Layer", "Pomocná vrstva"), + ("layer.environment", "Environment Layer", "Vrstva prostředí"), + ("", "Markers", "Značky"), + ("layer.addMarker", "Add Marker", "Přidat značku"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizovat značky ze zdroje"), + ("layer.markersLock", "Lock Markers", "Zamknout značky"), + ("layer.deleteAllMarkers", "Delete All Markers", "Odstranit všechny značky"), + ("layer.setSwitch", "Preserve Transparency", "Zachovat průhlednost"), + ("", "Blending Mode", "Režim prolnutí"), + ("layer.setBlendMode", "Normal", "Normální"), + ("layer.setBlendMode", "Dissolve", "Rozpustit"), + ("layer.setBlendMode", "Dancing Dissolve", "Tančící rozpuštění"), + ("layer.setBlendMode", "Darken", "Ztmavit"), + ("layer.setBlendMode", "Multiply", "Násobit"), + ("layer.setBlendMode", "Color Burn", "Ztmavit barvy"), + ("layer.setBlendMode", "Classic Color Burn", "Klasické ztmavení barev"), + ("layer.setBlendMode", "Linear Burn", "Lineárně ztmavit"), + ("layer.setBlendMode", "Darker Color", "Tmavší barva"), + ("layer.setBlendMode", "Add", "Přičíst"), + ("layer.setBlendMode", "Lighten", "Zesvětlit"), + ("layer.setBlendMode", "Screen", "Závoj"), + ("layer.setBlendMode", "Color Dodge", "Zesvětlit barvy"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasické zesvětlení barev"), + ("layer.setBlendMode", "Linear Dodge", "Lineárně zesvětlit"), + ("layer.setBlendMode", "Lighter Color", "Světlejší barva"), + ("layer.setBlendMode", "Overlay", "Překrýt"), + ("layer.setBlendMode", "Soft Light", "Měkké světlo"), + ("layer.setBlendMode", "Hard Light", "Tvrdé světlo"), + ("layer.setBlendMode", "Linear Light", "Lineární světlo"), + ("layer.setBlendMode", "Vivid Light", "Jasné světlo"), + ("layer.setBlendMode", "Pin Light", "Kolíkové světlo"), + ("layer.setBlendMode", "Hard Mix", "Tvrdé míchání"), + ("layer.setBlendMode", "Difference", "Rozdíl"), + ("layer.setBlendMode", "Classic Difference", "Klasický rozdíl"), + ("layer.setBlendMode", "Exclusion", "Vyloučit"), + ("layer.setBlendMode", "Subtract", "Odečíst"), + ("layer.setBlendMode", "Divide", "Dělit"), + ("layer.setBlendMode", "Hue", "Odstín"), + ("layer.setBlendMode", "Saturation", "Sytost"), + ("layer.setBlendMode", "Color", "Barva"), + ("layer.setBlendMode", "Luminosity", "Světlost"), + ("layer.setBlendMode", "Stencil Alpha", "Šablona alfa"), + ("layer.setBlendMode", "Stencil Luma", "Šablona jasu"), + ("layer.setBlendMode", "Silhouette Alpha", "Silueta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Silueta jasu"), + ("layer.setBlendMode", "Alpha Add", "Přičíst alfu"), + ("layer.setBlendMode", "Luminescent Premul", "Luminiscenční přednásobení"), + ("layer.setBlendMode", "Next Blending Mode", "Následující režim prolnutí"), + ("layer.setBlendMode", "Previous Blending Mode", "Předchozí režim prolnutí"), + ("", "Track Matte", "Maska stopy"), + ("layer.trackMatte", "No Track Matte", "Bez masky stopy"), + ("layer.trackMatte", "Alpha Matte", "Alfa maska"), + ("layer.trackMatte", "Alpha Inverted Matte", "Invertovaná alfa maska"), + ("layer.trackMatte", "Luma Matte", "Maska jasu"), + ("layer.trackMatte", "Luma Inverted Matte", "Invertovaná maska jasu"), + ("layer.trackMatte", "Matte with Layer Above", "Maskovat vrstvou výše"), + ("layer.trackMatte", "Matte with Layer Below", "Maskovat vrstvou níže"), + ("", "Layer Styles", "Styly vrstvy"), + ("layer.style.options", "Layer Style Options...", "Možnosti stylu vrstvy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Převést na upravitelné styly"), + ("layer.style.showAll", "Show All", "Zobrazit vše"), + ("layer.style.removeAll", "Remove All", "Odstranit vše"), + ("layer.style.dropShadow", "Drop Shadow", "Vržený stín"), + ("layer.style.innerShadow", "Inner Shadow", "Vnitřní stín"), + ("layer.style.outerGlow", "Outer Glow", "Vnější záře"), + ("layer.style.innerGlow", "Inner Glow", "Vnitřní záře"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Úkos a reliéf"), + ("layer.style.satin", "Satin", "Satén"), + ("layer.style.colorOverlay", "Color Overlay", "Překrytí barvou"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Překrytí přechodem"), + ("layer.style.stroke", "Stroke", "Tah"), + ("path.groupShapes", "Group Shapes", "Seskupit tvary"), + ("path.ungroupShapes", "Ungroup Shapes", "Zrušit seskupení tvarů"), + ("", "Arrange", "Uspořádat"), + ("layer.arrange", "Bring Layer to Front", "Přenést vrstvu do popředí"), + ("layer.arrange", "Bring Layer Forward", "Posunout vrstvu vpřed"), + ("layer.arrange", "Send Layer Backward", "Posunout vrstvu vzad"), + ("layer.arrange", "Send Layer to Back", "Přenést vrstvu do pozadí"), + ("", "Reveal", "Zobrazit"), + ("layer.revealSource", "Reveal Layer Source in Project", "Zobrazit zdroj vrstvy v projektu"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Zobrazit vrstvu ve vývojovém diagramu projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Zobrazit kompozici v projektu"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Zobrazit chyby výrazů"), + ("", "Create", "Vytvořit"), + ("layer.create", "Convert to Editable Text", "Převést na upravitelný text"), + ("layer.create", "Create Shapes from Text", "Vytvořit tvary z textu"), + ("layer.create", "Create Masks from Text", "Vytvořit masky z textu"), + ("layer.create", "Create Shapes from Vector Layer", "Vytvořit tvary z vektorové vrstvy"), + ("layer.create", "Create Keyframes from Data", "Vytvořit klíčové snímky z dat"), + ("layer.create", "Null Controllers for Positional Points", "Prázdné ovladače pro poziční body"), + ("layer.create", "Null Controllers for Path Points", "Prázdné ovladače pro body cesty"), + ("layer.create", "Nulls Following Path Points", "Prázdné objekty sledující body cesty"), + ("layer.create", "Null Tracing Along Path", "Prázdný objekt pohybující se po cestě"), + ("layer.create", "Create 3D Layer Instance", "Vytvořit instanci 3D vrstvy"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Vytvořit kameru z 3D zobrazení"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Vytvořit stereoskopickou 3D sestavu"), + ("camera.orbitNull", "Create Orbit Null", "Vytvořit prázdný objekt pro obíhání"), + ("camera.fromModel", "Create Cameras from 3D Model", "Vytvořit kamery z 3D modelu"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Propojit vzdálenost zaostření s bodem zájmu"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Propojit vzdálenost zaostření s vrstvou"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Nastavit vzdálenost zaostření na vrstvu"), + ("layer.cameraSettings", "Camera Settings...", "Nastavení kamery..."), + ("view.reset3DView", "Reset 3D View", "Obnovit 3D zobrazení"), + ("", "Light", "Světlo"), + ("light.fromModel", "Create Lights from 3D Model", "Vytvořit světla z 3D modelu"), + ("light.controlWithCamera", "Control Light with Camera", "Ovládat světlo kamerou"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Vytvořit vrstvu pozadí pro světlo prostředí"), + ("", "Material", "Materiál"), + ("material.revealSource", "Reveal Material Source in Project", "Zobrazit zdroj materiálu v projektu"), + ("material.reset", "Reset Material", "Obnovit materiál"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Duplikovat a přiřadit materiál"), + ("layer.autoTrace", "Auto-trace...", "Automaticky obkreslit..."), + ("layer.precompose", "Pre-compose...", "Předkomponovat..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Rozpoznat střihy scén..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ovládací prvky efektu"), + ("effect.applyLast", "Last Effect", "Poslední efekt"), + ("effect.removeAll", "Remove All", "Odstranit vše"), + ("effect.manage", "Manage Effects...", "Spravovat efekty..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Načíst zásuvný modul efektu..."), + ("", "Animation", "Animace"), + ("anim.savePreset", "Save Animation Preset...", "Uložit přednastavení animace..."), + ("anim.applyPreset", "Apply Animation Preset...", "Použít přednastavení animace..."), + ("", "Recent Animation Presets", "Nedávná přednastavení animace"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Vymazat nedávná přednastavení"), + ("anim.browsePresets", "Browse Presets...", "Procházet přednastavení..."), + ("", "Text Animation Presets", "Přednastavení animace textu"), + ("layer.applyTextPreset", "Typewriter", "Psací stroj"), + ("layer.applyTextPreset", "Fade Up Characters", "Postupně zobrazit znaky"), + ("layer.applyTextPreset", "Bounce In Words", "Slova s odskokem"), + ("layer.applyTextPreset", "Tracking In", "Zúžit prostrkání"), + ("layer.applyTextPreset", "Scramble", "Promíchat znaky"), + ("layer.applyTextPreset", "Blur In", "Zaostřit text"), + ("layer.applyTextPreset", "Jitter", "Chvění"), + ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), + ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), + ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), + ("", "Keyframe Assistant", "Pomocník klíčových snímků"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Převést zvuk na klíčové snímky"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Převést výraz na klíčové snímky"), + ("keys.easyEase", "Easy Ease", "Snadné zpomalení"), + ("keys.easyEaseIn", "Easy Ease In", "Snadné zpomalení na vstupu"), + ("keys.easyEaseOut", "Easy Ease Out", "Snadné zpomalení na výstupu"), + ("keys.exponentialScale", "Exponential Scale", "Exponenciální měřítko"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Seřadit vrstvy za sebe..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Obrátit čas klíčových snímků"), + ("", "Animate Text", "Animovat text"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Povolit 3D pro jednotlivé znaky"), + ("layer.addTextAnimator", "Anchor Point", "Kotevní bod"), + ("layer.addTextAnimator", "Position", "Pozice"), + ("layer.addTextAnimator", "Scale", "Měřítko"), + ("layer.addTextAnimator", "Skew", "Zkosení"), + ("layer.addTextAnimator", "Rotation", "Otočení"), + ("layer.addTextAnimator", "Opacity", "Krytí"), + ("layer.addTextAnimator", "All Transform Properties", "Všechny vlastnosti transformace"), + ("", "Fill Color", "Barva výplně"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Odstín"), + ("layer.addTextAnimator", "Saturation", "Sytost"), + ("layer.addTextAnimator", "Brightness", "Jas"), + ("", "Stroke Color", "Barva tahu"), + ("layer.addTextAnimator", "Stroke Width", "Šířka tahu"), + ("layer.addTextAnimator", "Tracking", "Prostrkání"), + ("layer.addTextAnimator", "Line Anchor", "Kotevní bod řádku"), + ("layer.addTextAnimator", "Line Spacing", "Řádkování"), + ("layer.addTextAnimator", "Character Offset", "Posun znaku"), + ("layer.addTextAnimator", "Character Value", "Hodnota znaku"), + ("layer.addTextAnimator", "Blur", "Rozostření"), + ("text.animatorFontAxes", "Variable Font Axes", "Osy variabilního písma"), + ("", "Add Text Selector", "Přidat selektor textu"), + ("text.addSelector", "Range", "Rozsah"), + ("text.addSelector", "Wiggly", "Chvějivý"), + ("text.addSelector", "Expression", "Výraz"), + ("text.removeAllAnimators", "Remove All Text Animators", "Odstranit všechny animátory textu"), + ("prop.setExpression", "Add Expression", "Přidat výraz"), + ("essential.addProperty", "Add Property to Essential Graphics", "Přidat vlastnost do základní grafiky"), + ("prop.separateDimensions", "Separate Dimensions", "Oddělit rozměry"), + ("track.camera", "Track Camera", "Sledovat kameru"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Stabilizátor deformací VFX"), + ("track.motion", "Track Motion", "Sledovat pohyb"), + ("track.stabilize", "Stabilize Motion", "Stabilizovat pohyb"), + ("track.property", "Track this Property", "Sledovat tuto vlastnost"), + ("anim.reveal", "Reveal Properties with Keyframes", "Zobrazit vlastnosti s klíčovými snímky"), + ("anim.reveal", "Reveal Properties with Animation", "Zobrazit vlastnosti s animací"), + ("anim.reveal", "Reveal All Modified Properties", "Zobrazit všechny změněné vlastnosti"), + ("", "View", "Zobrazení"), + ("view.newViewer", "New Viewer", "Nový prohlížeč"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Rozdělit s novým zamknutým prohlížečem"), + ("view.zoomIn", "Zoom In", "Přiblížit"), + ("view.zoomOut", "Zoom Out", "Oddálit"), + ("", "Resolution", "Rozlišení"), + ("view.res.full", "Full", "Plné"), + ("view.res.half", "Half", "Poloviční"), + ("view.res.third", "Third", "Třetinové"), + ("view.res.quarter", "Quarter", "Čtvrtinové"), + ("view.res.custom", "Custom...", "Vlastní..."), + ("view.displayColorManagement", "Use Display Color Management", "Použít správu barev monitoru"), + ("", "Simulate Output", "Simulovat výstup"), + ("view.simulateOutput", "No Output Simulation", "Bez simulace výstupu"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starší Macintosh RGB (gama 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Internetový standard RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Lineární (gama 1.0)"), + ("view.customRgb", "My Custom RGB...", "Moje vlastní RGB..."), + ("view.simulateOutput", "Custom...", "Vlastní..."), + ("view.rulers", "Show Rulers", "Zobrazit pravítka"), + ("", "Panel Background Color", "Barva pozadí panelu"), + ("view.panelBackground", "Black", "Černá"), + ("view.panelBackground", "Dark Gray", "Tmavě šedá"), + ("view.panelBackground", "Medium Gray (Default)", "Středně šedá (výchozí)"), + ("view.panelBackground", "Light Gray", "Světle šedá"), + ("view.panelBackground", "White", "Bílá"), + ("view.panelBackground", "Custom", "Vlastní"), + ("view.panelBackground", "Select Custom Background Color...", "Vybrat vlastní barvu pozadí..."), + ("view.guides", "Show Guides", "Zobrazit vodítka"), + ("view.snapToGuides", "Snap to Guides", "Přichytávat k vodítkům"), + ("view.lockGuides", "Lock Guides", "Zamknout vodítka"), + ("view.addGuide", "Add Guide...", "Přidat vodítko..."), + ("view.clearGuides", "Clear Guides", "Vymazat vodítka"), + ("view.importGuides", "Import Guides...", "Importovat vodítka..."), + ("view.exportGuides", "Export Guides...", "Exportovat vodítka..."), + ("view.grid", "Show Grid", "Zobrazit mřížku"), + ("view.snapToGrid", "Snap to Grid", "Přichytávat k mřížce"), + ("view.snapping", "Snapping", "Přichytávání"), + ("view.options", "View Options...", "Možnosti zobrazení..."), + ("view.layerControls", "Show Layer Controls", "Zobrazit ovládací prvky vrstvy"), + ("", "Switch View Layout", "Přepnout rozložení zobrazení"), + ("view.layout", "1 View", "1 zobrazení"), + ("view.layout", "2 Views", "2 zobrazení"), + ("view.layout", "4 Views", "4 zobrazení"), + ("view.shareViewOptions", "Share View Options", "Sdílet možnosti zobrazení"), + ("", "Switch 3D View", "Přepnout 3D zobrazení"), + ("view.3d.activeCamera", "Active Camera", "Aktivní kamera"), + ("view.3d.default", "Default", "Výchozí"), + ("view.3d.front", "Front", "Zepředu"), + ("view.3d.left", "Left", "Zleva"), + ("view.3d.top", "Top", "Shora"), + ("view.3d.back", "Back", "Zezadu"), + ("view.3d.right", "Right", "Zprava"), + ("view.3d.bottom", "Bottom", "Zdola"), + ("view.3d.custom1", "Custom View 1", "Vlastní zobrazení 1"), + ("view.3d.custom2", "Custom View 2", "Vlastní zobrazení 2"), + ("view.3d.custom3", "Custom View 3", "Vlastní zobrazení 3"), + ("", "Assign Shortcut to 3D View", "Přiřadit zkratku 3D zobrazení"), + ("view.3d.last", "Switch to Last 3D View", "Přepnout na poslední 3D zobrazení"), + ("view.lookAtSelected", "Look at Selected Layers", "Zaměřit vybrané vrstvy"), + ("view.lookAtAll", "Look at All Layers", "Zaměřit všechny vrstvy"), + ("time.set", "Go to Time...", "Přejít na čas..."), + ("view.fullScreen", "Enter Full Screen", "Přejít na celou obrazovku"), + ("", "Window", "Okno"), + ("", "Workspace", "Pracovní prostor"), + ("window.workspace", "Default", "Výchozí"), + ("window.workspace", "Review", "Revize"), + ("window.workspace", "Learn", "Výuka"), + ("window.workspace", "Small Screen", "Malá obrazovka"), + ("window.workspace", "Standard", "Standardní"), + ("window.workspace", "All Panels", "Všechny panely"), + ("window.workspace", "Animation", "Animace"), + ("window.workspace", "Color", "Barva"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Základní grafika"), + ("window.workspace", "Minimal", "Minimální"), + ("window.workspace", "Motion Tracking", "Sledování pohybu"), + ("window.workspace", "Paint", "Malování"), + ("window.workspace", "Text", "Text"), + ("window.workspace", "Undocked Panels", "Odpojené panely"), + ("window.resetWorkspace", "Reset to Saved Layout", "Obnovit uložené rozložení"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Uložit změny tohoto pracovního prostoru"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Uložit jako nový pracovní prostor..."), + ("window.editWorkspaces", "Edit Workspaces...", "Upravit pracovní prostory..."), + ("", "Assign Shortcut to Workspace", "Přiřadit zkratku pracovnímu prostoru"), + ("window.panel", "Align", "Zarovnání"), + ("window.panel", "Audio", "Zvuk"), + ("window.panel", "Brushes", "Štětce"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Výplň podle obsahu"), + ("window.panel", "Effects & Presets", "Efekty a přednastavení"), + ("window.panel", "Essential Graphics", "Základní grafika"), + ("window.panel", "Info", "Informace"), + ("help.inAppTutorials", "Learn", "Výuka"), + ("window.panel", "Lumetri Scopes", "Měřicí přístroje Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolace masek"), + ("window.panel", "Media Browser", "Prohlížeč médií"), + ("window.panel", "Metadata", "Metadata"), + ("window.panel", "Motion Sketch", "Náčrt pohybu"), + ("window.panel", "Paint", "Malování"), + ("window.panel", "Paragraph", "Odstavec"), + ("window.panel", "Preview", "Náhled"), + ("window.panel", "Progress", "Průběh"), + ("window.panel", "Properties", "Vlastnosti"), + ("window.panel", "Script Console", "Konzole skriptů"), + ("window.panel", "Smoother", "Vyhlazení"), + ("window.panel", "Tools", "Nástroje"), + ("window.panel", "Tracker", "Sledování"), + ("window.panel", "Wiggler", "Generátor chvění"), + ("window.panel", "Composition", "Kompozice"), + ("window.panel", "Flowchart", "Vývojový diagram"), + ("window.panel", "Footage", "Záznam"), + ("window.panel", "Layer", "Vrstva"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Fronta vykreslování"), + ("window.panel", "Timeline", "Časová osa"), + ("window.panel", "Create Nulls From Paths", "Vytvořit prázdné objekty z cest"), + ("window.panel", "VR Comp Editor", "Editor kompozice VR"), + ("", "Help", "Nápověda"), + ("help.docs", "EffectCraft Help...", "Nápověda k EffectCraft..."), + ("help.docs", "Scripting Help...", "Nápověda ke skriptování..."), + ("help.docs", "Expression Reference...", "Referenční příručka výrazů..."), + ("help.docs", "Effect Reference...", "Referenční příručka efektů..."), + ("anim.browsePresets", "Animation Presets...", "Přednastavení animace..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Klávesové zkratky..."), + ("help.inAppTutorials", "In-App Tutorials...", "Výukové lekce v aplikaci..."), + ("help.onlineTutorials", "Online Tutorials...", "Výukové lekce online..."), + ("help.systemReport", "System Compatibility Report...", "Zpráva o kompatibilitě systému..."), + ("help.enableLogging", "Enable Logging", "Povolit protokolování"), + ("help.revealLogFile", "Reveal Logging File", "Zobrazit soubor protokolu"), + ("help.discord", "Join the ArtCraft Discord...", "Připojit se na Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Poskytnout zpětnou vazbu..."), + ("help.website", "ArtCraft Website", "Web ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Domovská stránka EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft na GitHubu"), + ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), +]; + #[cfg(test)] mod tests { use super::*; @@ -4008,6 +6556,86 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + #[test] + fn czech_translates_menus_without_changing_bindings_or_user_names() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); + app.session.prefs.push_recent("File.ecproj"); + let en = crate::native_menu::build(&app); + let state = crate::native_menu::state_key(&app); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + let cs = crate::native_menu::build(&app); + assert_eq!(language(&app), "cs"); + assert!(czech(&app)); + assert_eq!(code("cs"), "cs"); + assert_ne!(state, crate::native_menu::state_key(&app)); + let bindings = |menu: &crate::native_menu::NativeMenu| { + menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() + }; + assert_eq!(bindings(&en), bindings(&cs)); + assert!(cs.items().iter().any(|i| i.command == "comp.new" && i.label == "Nová kompozice...")); + assert!(cs.items().iter().any(|i| i.command == "layer.newText" && i.label == "Text")); + assert!(cs.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "Snadné zpomalení")); + assert!(cs.items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + for (source, translated) in [ + ("File", "Soubor"), + ("Edit", "Úpravy"), + ("Composition", "Kompozice"), + ("Layer", "Vrstva"), + ("Effect", "Efekt"), + ("Animation", "Animace"), + ("View", "Zobrazení"), + ("Window", "Okno"), + ("Help", "Nápověda"), + ] { + assert_eq!(label(&app, "", source), translated); + } + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None }; + assert_eq!(entry(&app, &panel, "Layer: File Čeština".into()), "Vrstva: File Čeština"); + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Čeština” Workspace".into()), + "Přiřadit zkratku pracovnímu prostoru „File Čeština“" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Přiřadit zkratku 3D zobrazení „Top“"); + assert_eq!(submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to Workspace".into()), "Přiřadit zkratku pracovnímu prostoru"); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); + assert_eq!(crate::native_menu::build(&app), en); + } + + #[test] + fn czech_undo_redo_keeps_history_labels_verbatim() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Není co vrátit"); + assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Není co zopakovat"); + app.session.history.undo.push(("File Čeština".into(), app.session.project.clone())); + app.session.history.redo.push(("Text Čeština".into(), app.session.project.clone())); + assert_eq!(entry(&app, &undo, "Undo File Čeština".into()), "Zpět File Čeština"); + assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština"); + } + + #[test] + fn czech_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Zobrazit ve Finderu"), + ("Reveal in Explorer", "Zobrazit v Průzkumníku"), + ("Reveal in File Manager", "Zobrazit ve správci souborů"), + ] { + let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None }; + assert_eq!(label(&app, command, source), translated); + assert_eq!(entry(&app, &e, source.into()), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4"); + } + } + fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -4035,6 +6663,10 @@ mod tests { let mut ukrainian_keys = BTreeSet::new(); let mut spanish_keys = BTreeSet::new(); let mut portuguese_keys = BTreeSet::new(); + let mut russian_keys = BTreeSet::new(); + let mut french_keys = BTreeSet::new(); + let mut german_keys = BTreeSet::new(); + let mut czech_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), @@ -4042,6 +6674,10 @@ mod tests { (UKRAINIAN, &mut ukrainian_keys), (SPANISH, &mut spanish_keys), (PORTUGUESE, &mut portuguese_keys), + (RUSSIAN, &mut russian_keys), + (FRENCH, &mut french_keys), + (GERMAN, &mut german_keys), + (CZECH, &mut czech_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -4068,6 +6704,10 @@ mod tests { assert_eq!(traditional_keys, spanish_keys, "the languages must translate the same entries"); assert_eq!(spanish_keys, portuguese_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, ukrainian_keys, "the languages must translate the same entries"); + assert_eq!(ukrainian_keys, russian_keys, "the languages must translate the same entries"); + assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); + assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); + assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); } /// Exercise each engine spelling on every host: a macOS-only run must still catch a @@ -4149,6 +6789,10 @@ mod tests { (Some("es-419"), "es"), (Some("pt-BR"), "pt-br"), (Some("pt_PT.UTF-8"), "pt-br"), + (Some("cs"), "cs"), + (Some("cs-CZ"), "cs"), + (Some("cs_CZ.UTF-8"), "cs"), + (Some("CS_cz.UTF-8"), "cs"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -4162,6 +6806,7 @@ mod tests { assert_eq!(simplified(&app), system_language() == "zh-hans"); assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); + assert_eq!(czech(&app), system_language() == "cs"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -4181,7 +6826,9 @@ mod tests { #[test] fn every_translated_entry_keeps_the_english_shape() { - for (code, catalog) in [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE)] { + for (code, catalog) in + [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)] + { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 987cfc93..f31d0951 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -44,11 +44,15 @@ fn catalog(language: &str) -> Option<&'static Index> { static HANS: OnceLock = OnceLock::new(); static HANT: OnceLock = OnceLock::new(); static UK: OnceLock = OnceLock::new(); + static PT_BR: OnceLock = OnceLock::new(); + static CS: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), "zh-hant" => (&HANT, TRADITIONAL_CHINESE), "uk" => (&UK, UKRAINIAN), + "pt-br" => (&PT_BR, PORTUGUESE), + "cs" => (&CS, CZECH), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -81,6 +85,9 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { /// Japanese: the same rows, in the same order, as the Chinese catalogs below (the tests keep the two in step). const JAPANESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外観設定…"), + ("Add to favorites", "お気に入りに追加"), + ("Remove from favorites", "お気に入りから削除"), (" OK ", " OK "), (" Delete ", " 削除 "), (" Open Auto-Save ", " 自動保存を開く "), @@ -491,6 +498,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Type a command or effect…", "コマンドまたはエフェクトを入力…"), ("URL:", "URL:"), ("Undock Panel", "パネルのドッキングを解除"), + ("Move Panel to New Window", "新規ウィンドウにパネルを移動"), + ("Move Panel to Main Window", "メインウィンドウにパネルを移動"), ("Unlink {}", "{}のリンクを解除"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -519,6 +528,9 @@ const JAPANESE: &[(&str, &str)] = &[ ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外观设置…"), + ("Add to favorites", "添加到收藏"), + ("Remove from favorites", "从收藏中移除"), (" OK ", " 确定 "), (" Delete ", " 删除 "), (" Open Auto-Save ", " 打开自动保存 "), @@ -931,6 +943,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "将面板移至新窗口"), + ("Move Panel to Main Window", "将面板移回主窗口"), ("Unfreeze", "解冻"), ("Unlink {}", "取消链接 {}"), ( @@ -1049,6 +1063,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外觀設定…"), + ("Add to favorites", "加入最愛"), + ("Remove from favorites", "從最愛移除"), (" OK ", " 確定 "), (" Delete ", " 刪除 "), (" Open Auto-Save ", " 開啟自動儲存 "), @@ -1461,6 +1478,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "將面板移至新視窗"), + ("Move Panel to Main Window", "將面板移回主視窗"), ("Unfreeze", "解凍"), ("Unlink {}", "取消鏈接 {}"), ( @@ -1576,6 +1595,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. /// Technical names, key names and substituted project content keep their original spelling. const UKRAINIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "Налаштування вигляду…"), + ("Add to favorites", "Додати до вибраного"), + ("Remove from favorites", "Видалити з вибраного"), (" OK ", " Гаразд "), (" Delete ", " Видалити "), (" Open Auto-Save ", " Відкрити автозбереження "), @@ -1588,7 +1610,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ), ("About EffectCraft", "Про EffectCraft"), ("About...", "Про програму..."), + ("Active", "Активна"), ("Active Camera", "Активна камера"), + ("Alpha", "Альфа"), ("Adapt Feature On Every Frame", "Адаптувати ознаку в кожному кадрі"), ("Adaptive Sample Limit:", "Межа адаптивних вибірок:"), ("Add Animator", "Додати аніматор"), @@ -1609,6 +1633,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "Усі шість камер граней повертаються разом; сцена залишається без змін."), ("Anchor:", "Опорна точка:"), ("Aperture", "Діафрагма"), + ("Appearance Mode", "Режим оформлення"), + ("Appearance Mode: {}", "Режим оформлення: {}"), ("Apply Dimensions:", "Застосувати виміри:"), ("Apply Motion To:", "Застосувати рух до:"), ("Arrange", "Упорядкувати"), @@ -1621,6 +1647,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Blending Mode", "Режим змішування"), ("Blur", "Розмиття"), ("Blur Level", "Рівень розмиття"), + ("Boundary", "Межа"), ("Browser Storage", "Сховище браузера"), ("Browse…", "Огляд…"), ("Brush Dynamics", "Динаміка пензля"), @@ -1674,6 +1701,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "Безперервна (прирівняти вихідну швидкість до вхідної)"), ("Cue Point", "Сигнальна точка"), ("Current Render", "Поточний рендеринг"), + ("Dark", "Темне"), + ("Dark Theme", "Темна тема"), ("Darker", "Темніше"), ("Default Output:", "Стандартний вихід:"), ("Default: {}", "Стандартне: {}"), @@ -1736,6 +1765,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Frame Rate:", "Частота кадрів:"), ("Frame Render Time {}ms", "Час рендерингу кадру {} мс"), ("Frame Target:", "Цільовий фрейм:"), + ("Freeze", "Заморозити"), ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), @@ -1771,6 +1801,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "Залишити всі атрибути в «{}»"), ("Licence: {}", "Ліцензія: {}"), ("Ligatures", "Лігатури"), + ("Light", "Світле"), + ("Light Theme", "Світла тема"), ("Light Type", "Тип світла"), ("Lighter", "Світліше"), ("Link Selected Property", "Зв’язати виділену властивість"), @@ -1853,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Original Duration:", "Початкова тривалість:"), ("Output Cycle: {}", "Цикл виводу: {}"), ("Output To:", "Вивести до:"), + ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), @@ -1951,6 +1984,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Start Timecode:", "Початковий часовий код:"), ("Start: {} Duration: {}", "Початок: {} Тривалість: {}"), ("Step {} of {}: {}", "Крок {} з {}: {}"), + ("Stop", "Зупинити"), ("Stop {} at {}°", "Колірна точка {} на {}°"), ("Stored in: {}", "Збережено в: {}"), ("Stretch", "Розтягнення"), @@ -1958,6 +1992,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Stylistic Sets", "Стилістичні набори"), ("Subpixel Positioning", "Субпіксельне позиціювання"), ("Swap Paint Colors (X)", "Поміняти місцями кольори малювання (X)"), + ("Sync with System", "Синхронізувати з системою"), ("System Default", "Стандартне системне"), ("Table", "Таблиця"), ("Template name", "Назва шаблону"), @@ -1992,6 +2027,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Type a command or effect…", "Введіть команду або ефект…"), ("URL:", "URL:"), ("Undock Panel", "Від’єднати панель"), + ("Unfreeze", "Розморозити"), + ("Move Panel to New Window", "Перемістити панель у нове вікно"), + ("Move Panel to Main Window", "Перемістити панель у головне вікно"), ("Unlink {}", "Від’єднати {}"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -2017,6 +2055,1066 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "Перетягніть опорну точку з ⌘/Ctrl, щоб записати її рух"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} використовується в:"), + // Strings a panel used to hand to a helper as a bare literal: the helper draws them (the + // viewer's toolbar tooltips and popup items, the solid dialog's checkbox), so the lookup has + // to reach the catalog from inside the helper. Kept at the end so the catalogs stay parallel. + ("3D Reference Axes", "Опорні осі 3D"), + ("Affect all layers that use this solid", "Застосувати до всіх шарів, що використовують цей суцільний колір"), + ("Choose grid and guide options", "Вибрати параметри сітки та напрямних"), + ("Colorize", "Розфарбувати"), + ("Custom", "Власна"), + ("Custom...", "Власна..."), + ("Fit", "Підігнати"), + ("Fit up to 100%", "Підігнати (макс. 100%)"), + ("Full", "Повна"), + ("Half", "Половина"), + ("Only this layer uses this solid.", "Цей суцільний колір використовує лише цей шар."), + ("Proportional Grid", "Пропорційна сітка"), + ("Quarter", "Чверть"), + ("Region of Interest", "Область інтересу"), + ("Reset Exposure", "Скинути експозицію"), + ("Show Channel and Color Management Settings", "Показати канал і параметри керування кольором"), + ("Show Snapshot (hold)", "Показати знімок (утримуйте)"), + ("Take Snapshot", "Зробити знімок"), + ("Third", "Третина"), + ("Toggle Mask and Shape Path Visibility", "Перемкнути видимість контурів масок і фігур"), + ("Toggle Transparency Grid", "Перемкнути сітку прозорості"), + // Technical terms and proper nouns: the project settings, the import and export forms, the + // viewer's channel and Fast Previews popups, the keyframe dialogs and the About window. The + // terms that read the same in Ukrainian (Display P3, HDTV (Rec. 709), RGB, H.264, ProRes…) + // need no row: a missing row falls back to the English source, which is the decision. + ("16 bits per channel", "16 бітів на канал"), + ("32 bits per channel (float)", "32 біти на канал (з плаваючою комою)"), + ("8 bits per channel", "8 бітів на канал"), + ("Adaptive Resolution", "Адаптивна роздільність"), + ("Adobe-style built-in", "Вбудований у стилі Adobe"), + ("Blue", "Синій"), + ("Current Settings", "Поточні налаштування"), + ("Disable Per-character 3D", "Вимкнути 3D для кожного символу"), + ("Draft", "Чорнова"), + ("Enable Per-character 3D", "Увімкнути 3D для кожного символу"), + ("Fast Draft", "Швидка чорнова"), + ("Fast Previews (Draft 3D, Faster Effects)", "Швидкий перегляд (чорновий 3D, швидші ефекти)"), + ("Green", "Зелений"), + ("Import Image Sequence", "Імпорт послідовності зображень"), + ("Import PDF / Illustrator File", "Імпорт файлу PDF / Illustrator"), + ("Import Photoshop File", "Імпорт файлу Photoshop"), + ("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"), + ("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"), + ("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"), + ("Mercury Software Only", "Лише програмний Mercury"), + ("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"), + ("Off (Final Quality)", "Вимкнено (остаточна якість)"), + ("OpenGL (compatibility)", "OpenGL (сумісність)"), + ("Opus Bitrate: {} kbps", "Бітрейт Opus: {} кбіт/с"), + ("RGB Straight", "RGB (прямий)"), + ("Red", "Червоний"), + ("Tone map", "Тональне відображення"), + ("Use In-Window Menu Bar on macOS", "Використовувати смугу меню у вікні на macOS"), + ("Wireframe", "Каркас"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (стандартний)"), + // The Home screen (#574). + ("New Project", "Новий проєкт"), + ("Open Project…", "Відкрити проєкт…"), + ("Templates", "Шаблони"), + ("EffectCraft home page", "Домашня сторінка EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft на GitHub"), + ("Motion graphics and visual effects · version {}", "Анімована графіка та візуальні ефекти · версія {}"), + ("New Composition", "Нова композиція"), + ("Open Demo Project", "Відкрити демонстраційний проєкт"), + ("Import Footage…", "Імпортувати матеріал…"), + ("Filter recent files", "Фільтрувати нещодавні файли"), + ("NAME", "НАЗВА"), + ("OPENED", "ВІДКРИТО"), + ("SIZE", "РОЗМІР"), + ("KIND", "ТИП"), + ("Your recent files will show here", "Тут з’являться ваші нещодавні файли"), + ("Create a new project, or open the demo project to look around.", "Створіть новий проєкт або відкрийте демонстраційний, щоб ознайомитися з програмою."), + ("No recent files match the filter", "Немає нещодавніх файлів, що відповідають фільтру"), + ("Try another name or folder.", "Спробуйте іншу назву або теку."), + ("Just now", "Щойно"), + ("Yesterday", "Учора"), + // `format_ago` picks between one and many, while Ukrainian declines the noun for 1, for 2–4 + // and for 5 and up; the invariant unit abbreviations read correctly for every count. + ("{} minute ago", "{} хв тому"), + ("{} minutes ago", "{} хв тому"), + ("{} hour ago", "{} год тому"), + ("{} hours ago", "{} год тому"), + ("{} day ago", "{} дн. тому"), + ("{} days ago", "{} дн. тому"), + ("EffectCraft project", "Проєкт EffectCraft"), + ("After Effects project", "Проєкт After Effects"), + ("Lottie animation", "Анімація Lottie"), + ("Project", "Проєкт"), +]; + +// Brazilian Portuguese. +const PORTUGUESE: &[(&str, &str)] = &[ + (" OK ", " OK "), + (" Delete ", " Excluir "), + (" Open Auto-Save ", " Abrir salvamento automático "), + ("(missing)", "(ausente)"), + ("360 View Orientation", "Orientação da vista 360°"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Um compositor de código aberto, desenvolvido em clean-room, para motion graphics e efeitos visuais: nativo no macOS, Windows e Linux, e no navegador. Parte da família de aplicativos criativos ArtCraft.", + ), + ("About EffectCraft", "Sobre o EffectCraft"), + ("About...", "Sobre..."), + ("Active Camera", "Câmera ativa"), + ("Adapt Feature On Every Frame", "Adaptar o recurso em cada quadro"), + ("Adaptive Sample Limit:", "Limite de amostras adaptativas:"), + ("Add Animator", "Adicionar animador"), + ("Add Comment", "Adicionar comentário"), + ("Add Formatting", "Adicionar formatação"), + ("Add Group", "Adicionar grupo"), + ("Add Mirror", "Adicionar espelho"), + ("Add Selected As", "Adicionar selecionados como"), + ("Add a comment", "Adicionar um comentário"), + ("Add a shape, fill, stroke or path operation", "Adicionar uma forma, preenchimento, traço ou operação de caminho"), + ("Add to Composition", "Adicionar à composição"), + ("Add to Favorites", "Adicionar aos favoritos"), + ("Add:", "Adicionar:"), + ("Adjust Exposure (stops)", "Ajustar exposição (stops)"), + ("Adjust composition duration to the time span of the selected layers", "Ajustar a duração da composição ao intervalo de tempo das camadas selecionadas"), + ("Advanced 3D", "3D avançado"), + ("Align Layers to:", "Alinhar camadas a:"), + ("All six face cameras turn together; the scene is not changed.", "As seis câmeras das faces giram juntas; a cena não é alterada."), + ("Anchor:", "Âncora:"), + ("Aperture", "Abertura"), + ("Apply Dimensions:", "Aplicar dimensões:"), + ("Apply Motion To:", "Aplicar movimento a:"), + ("Arrange", "Organizar"), + ("Assign", "Atribuir"), + ("Auto", "Automático"), + ("Auto-saves", "Salvamentos automáticos"), + ("Back", "Voltar"), + ("Background Color:", "Cor do plano de fundo:"), + ("Blend Mode:", "Modo de mesclagem:"), + ("Blending Mode", "Modo de mesclagem"), + ("Blur", "Desfoque"), + ("Blur Level", "Nível de desfoque"), + ("Browser Storage", "Armazenamento do navegador"), + ("Browse…", "Procurar…"), + ("Brush Dynamics", "Dinâmica do pincel"), + ("Caching frames…", "Armazenando quadros em cache…"), + ("Cancel", "Cancelar"), + ("Casts Shadows", "Projeta sombras"), + ("Change Renderer…", "Alterar renderizador…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Alterar um atalho cria uma predefinição personalizada; a padrão permanece como está.", + ), + ("Channel", "Canal"), + ("Channel:", "Canal:"), + ("Chapter:", "Capítulo:"), + ("Choose...", "Escolher..."), + ("Classic 3D", "3D clássico"), + ("Clear", "Limpar"), + ("Clear list", "Limpar lista"), + ("Click a key to see its commands.", "Clique em uma tecla para ver seus comandos."), + ("Click adds a point (½ height = no change)", "Clique adiciona um ponto (½ altura = sem alteração)"), + ("Click adds a point; drag one off to remove", "Clique adiciona um ponto; arraste um para fora para remover"), + ("Click to make this viewer active", "Clique para tornar este visualizador ativo"), + ("Click to reveal the expression in the timeline", "Clique para revelar a expressão na linha do tempo"), + ("Click to show the active composition here", "Clique para mostrar a composição ativa aqui"), + ("Click, then click in the Composition panel", "Clique e depois clique no painel Composição"), + ("Clone Options", "Opções de clone"), + ("Clone Source Overlay:", "Sobreposição da origem do clone:"), + ("Close", "Fechar"), + ("Close Panel", "Fechar painel"), + ("Close tutorial", "Fechar tutorial"), + ("Color", "Cor"), + ("Color Depth: {}", "Profundidade de cor: {}"), + ("Color:", "Cor:"), + ("Colour map", "Mapa de cores"), + ("Columns", "Colunas"), + ("Comment:", "Comentário:"), + ("Community", "Comunidade"), + ("Composition", "Composição"), + ("Composition Mini-Flowchart", "Minifluxograma da composição"), + ("Composition Name:", "Nome da composição:"), + ("Composition Settings ▸ 3D Renderer", "Configurações da composição ▸ Renderizador 3D"), + ("Composition Settings…", "Configurações da composição…"), + ("Composition marker bin: drag a marker to the time ruler", "Compartimento de marcadores da composição: arraste um marcador para a régua de tempo"), + ("Cone Angle", "Ângulo do cone"), + ("Cone Feather", "Suavização do cone"), + ("Constrain Proportions", "Restringir proporções"), + ("Continuous (Lock Outgoing to Incoming)", "Contínuo (travar saída na entrada)"), + ("Cue Point", "Ponto de cue"), + ("Current Render", "Renderização atual"), + ("Darker", "Mais escuro"), + ("Default Output:", "Saída padrão:"), + ("Default: {}", "Padrão: {}"), + ("Defaults", "Padrões"), + ("Delete", "Excluir"), + ("Delete Selected Points", "Excluir pontos selecionados"), + ("Delete selected project items", "Excluir itens de projeto selecionados"), + ("Delete this template", "Excluir este modelo"), + ("Depth of Field", "Profundidade de campo"), + ("Discord", "Discord"), + ("Distribute Layers:", "Distribuir camadas:"), + ("Don't Recover", "Não recuperar"), + ("Don't Save", "Não salvar"), + ("Double-click a layer to open it in the Layer panel", "Clique duas vezes em uma camada para abri-la no painel Camada"), + ("Double-click footage in the Project panel to open it here", "Clique duas vezes em uma mídia no painel Projeto para abri-la aqui"), + ("Double-click to edit the comment", "Clique duas vezes para editar o comentário"), + ("Download", "Baixar"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Arraste propriedades da linha do tempo para cá,\nou use Animação > Adicionar propriedade aos Gráficos essenciais.", + ), + ("Drag to change", "Arraste para alterar"), + ("Drag to create a guide", "Arraste para criar uma guia"), + ("Drag to slip the source", "Arraste para deslizar a origem"), + ("Drop Frame", "Drop frame"), + ("Duplicate", "Duplicar"), + ("Duration:", "Duração:"), + ("Edit Gradient…", "Editar gradiente…"), + ("Edit Target: {}", "Editar destino: {}"), + ("Edit in the Timeline", "Editar na linha do tempo"), + ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), + ("Effects & Presets options", "Opções de Efeitos e predefinições"), + ("Effects: {}", "Efeitos: {}"), + ("Empty folder", "Pasta vazia"), + ("Enable Depth of Field", "Ativar profundidade de campo"), + ("Enable Expression", "Ativar expressão"), + ("Enhance", "Aprimorar"), + ("Essential Properties — {}", "Propriedades essenciais: {}"), + ("Event", "Evento"), + ("Export Template…", "Exportar modelo…"), + ("Export...", "Exportar..."), + ("Expression Language Menu", "Menu de linguagem de expressão"), + ("Expression pick whip: drag onto a property", "Pick whip de expressão: arraste até uma propriedade"), + ("Expression:", "Expressão:"), + ("Eyedropper: click, then click in the Composition panel", "Conta-gotas: clique e depois clique no painel Composição"), + ("Falloff", "Atenuação"), + ("Falloff Distance", "Distância de atenuação"), + ("Favorites", "Favoritos"), + ("Field Render: {}", "Renderização de campos: {}"), + ("Figures", "Numerais"), + ("Fill Target", "Destino do preenchimento"), + ("Finished", "Concluído"), + ("Flowchart: {}", "Fluxograma: {}"), + ("Focus Distance", "Distância de foco"), + ("Follow Feature Rotation and Scale", "Acompanhar rotação e escala do recurso"), + ("Footage: {}", "Mídia: {}"), + ("Format: {}", "Formato: {}"), + ("Frame Aspect Ratio: {}", "Proporção do quadro: {}"), + ("Frame Blending: {}", "Mesclagem de quadros: {}"), + ("Frame Rate:", "Taxa de quadros:"), + ("Frame Render Time {}ms", "Tempo de renderização do quadro {}ms"), + ("Frame Target:", "Destino do quadro:"), + ("Grab bag", "Lista"), + ("Grid", "Grade"), + ("Guide Layers: {}", "Camadas de guia: {}"), + ("Guides", "Guias"), + ("Height:", "Altura:"), + ("Hide This", "Ocultar isto"), + ("Histogram", "Histograma"), + ("Hold In Place", "Manter no lugar"), + ("Home", "Início"), + ("If Confidence is Below", "Se a confiança for inferior a"), + ("Import", "Importar"), + ("Import and Add to Composition", "Importar e adicionar à composição"), + ("Import...", "Importar..."), + ("Imported media", "Mídia importada"), + ("Influence:", "Influência:"), + ("Install from File…", "Instalar a partir de arquivo…"), + ("Intensity", "Intensidade"), + ("Inverted", "Invertido"), + ("Join the ArtCraft community on Discord", "Entre na comunidade ArtCraft no Discord"), + ("Key: {}", "Tecla: {}"), + ("LEARN", "APRENDER"), + ("Label", "Rótulo"), + ("Label Colors and Names:", "Cores e nomes dos rótulos:"), + ("Label:", "Rótulo:"), + ("Latest auto-save: {}", "Último salvamento automático: {}"), + ("Layer", "Camada"), + ("Layer controls", "Controles da camada"), + ("Layer {}", "Camada {}"), + ("Layer:", "Camada:"), + ("Layer: {}", "Camada: {}"), + ("Learn", "Aprender"), + ("Leave all attributes in \"{}\"", "Manter todos os atributos em \"{}\""), + ("Licence: {}", "Licença: {}"), + ("Ligatures", "Ligaduras"), + ("Light Type", "Tipo de luz"), + ("Lighter", "Mais claro"), + ("Link Selected Property", "Vincular propriedade selecionada"), + ("Lock Aspect Ratio to {}", "Travar proporção em {}"), + ("Lock to Zoom", "Travar no zoom"), + ("Locked: {} — {}", "Travado: {} / {}"), + ("Log:", "Registro:"), + ("Loop (Trace Path)", "Laço (traçar caminho)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OU Apache-2.0 • Fontes: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu de proporção de ampliação"), + ("Make Comp Size", "Ajustar ao tamanho da composição"), + ("Masks", "Máscaras"), + ("Maximize Panel", "Maximizar painel"), + ("Media Replacement", "Substituição de mídia"), + ("Mesh:", "Malha:"), + ("Diameter:", "Diâmetro:"), + ("Method Used: {} Average Error: {} pixels", "Método usado: {} Erro médio: {} pixels"), + ("Models folder: {}", "Pasta de modelos: {}"), + ("Modifiers:", "Modificadores:"), + ("More", "Mais"), + ("More ArtCraft apps", "Mais aplicativos ArtCraft"), + ("More tutorials", "Mais tutoriais"), + ("Motion Blur", "Desfoque de movimento"), + ("Motion Blur: {}", "Desfoque de movimento: {}"), + ("Motion Sketch: drag to record", "Motion Sketch: arraste para gravar"), + ("Move Down", "Mover para baixo"), + ("Move Up", "Mover para cima"), + ("Move to Project Root", "Mover para a raiz do projeto"), + ("Name", "Nome"), + ("Name:", "Nome:"), + ("Navigation", "Navegação"), + ("New", "Novo"), + ("New Comp from Selection", "Nova composição a partir da seleção"), + ("New Duration:", "Nova duração:"), + ("New composition name:", "Nome da nova composição:"), + ("New from Render Queue item:", "Novo a partir do item da fila de renderização:"), + ("New from Template", "Novo a partir de modelo"), + ("Next", "Próximo"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Bom trabalho. Escolha outro tutorial na guia Aprender da tela Início."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Nenhum ambiente de RV ainda. Crie um com Composição ▸ RV ▸ Criar ambiente de RV…", + ), + ("No audio layer selected", "Nenhuma camada de áudio selecionada"), + ("No background jobs running", "Nenhuma tarefa em segundo plano em execução"), + ("No composition markers", "Nenhum marcador de composição"), + ("No contributor data was built into this copy.", "Nenhum dado de colaboradores foi incluído nesta cópia."), + ("No device list from this host; the system default output is used.", "Este host não forneceu lista de dispositivos; a saída padrão do sistema é usada."), + ("No layer selected", "Nenhuma camada selecionada"), + ("No matching effects or presets", "Nenhum efeito ou predefinição correspondente"), + ("No matching fonts", "Nenhuma fonte correspondente"), + ("No model credits were built into this copy.", "Nenhum crédito de modelos foi incluído nesta cópia."), + ("No nested compositions", "Nenhuma composição aninhada"), + ("No shortcut", "Sem atalho"), + ("No unlocked layer selected", "Nenhuma camada desbloqueada selecionada"), + ("No video", "Sem vídeo"), + ("None", "Nenhum"), + ("Not applied: tick it in the list to add it.", "Não aplicado: marque-o na lista para adicioná-lo."), + ("Not assigned", "Não atribuído"), + ( + "Note: Shadows are only cast from layers with Cast Shadows enabled.", + "Observação: as sombras só são projetadas por camadas com Projetar sombras ativado.", + ), + ("Notify", "Notificar"), + ("OK", "OK"), + ("Offset:", "Deslocamento:"), + ("On ({}) stop:", "Ao parar ({}):"), + ("One-Node Camera", "Câmera de um nó"), + ("Opacity:", "Opacidade:"), + ("Open Composition", "Abrir composição"), + ("Open Last Saved Project", "Abrir último projeto salvo"), + ("Open New Composition", "Abrir nova composição"), + ("Open Output", "Abrir saída"), + ("Open a composition to build Essential Graphics", "Abra uma composição para criar Gráficos essenciais"), + ("Open a composition to see its scopes", "Abra uma composição para ver seus scopes"), + ("Open a composition to track motion.", "Abra uma composição para rastrear movimento."), + ("Opus Tuning: {}", "Ajuste do Opus: {}"), + ("Original Duration:", "Duração original:"), + ("Output Cycle: {}", "Ciclo de saída: {}"), + ("Output To:", "Saída para:"), + ("Parameter Name:", "Nome do parâmetro:"), + ("Parent pick whip: drag onto a layer", "Pick whip de pai: arraste até uma camada"), + ("Pixel Aspect Ratio:", "Proporção de pixel:"), + ("Playing on Video Preview", "Reproduzindo na visualização de vídeo"), + ("Post-Render Action: {}", "Ação pós-renderização: {}"), + ("Preserve frame rate when nested or in render queue", "Preservar a taxa de quadros quando aninhada ou na fila de renderização"), + ("Preserve resolution when nested", "Preservar a resolução quando aninhada"), + ("Preset", "Predefinição"), + ("Preset:", "Predefinição:"), + ("Presets", "Predefinições"), + ("Previous", "Anterior"), + ("Primary:", "Primário:"), + ("Process Before Match", "Processar antes de corresponder"), + ("Profile: {}", "Perfil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Profundidade de cor do projeto: clique para alternar entre 8/16/32 bpc, Alt+clique para Configurações do projeto", + ), + ("Project: {}", "Projeto: {}"), + ("Projects", "Projetos"), + ("Property pick whip: drag onto a property", "Pick whip de propriedade: arraste até uma propriedade"), + ("Protected Region", "Região protegida"), + ("Proxy Use: {}", "Uso de proxy: {}"), + ("Push All to Comp", "Enviar tudo para a composição"), + ("Radius", "Raio"), + ("Rate Control: {}", "Controle de taxa: {}"), + ("Recent", "Recentes"), + ("Recording", "Gravando"), + ("Remove", "Remover"), + ("Rename", "Renomear"), + ("Render: {} ms • UI {} fps{}", "Renderização: {} ms • IU {} fps{}"), + ("Renderer:", "Renderizador:"), + ("Request Persistent Storage", "Solicitar armazenamento persistente"), + ("Reset", "Redefinir"), + ("Reset Labels", "Redefinir rótulos"), + ("Reset Preset", "Redefinir predefinição"), + ("Reset to Default", "Redefinir para o padrão"), + ("Reset to Saved Layout", "Redefinir para o layout salvo"), + ("Resolution/Down Sample Factor Popup", "Menu de resolução/fator de redução de amostragem"), + ("Restore Panel Size", "Restaurar tamanho do painel"), + ("Reverse the order", "Inverter a ordem"), + ("Revert All", "Reverter tudo"), + ("Roman Hanging Punctuation", "Pontuação romana suspensa"), + ("Roving:", "Roving:"), + ("Ruler zero point (drag to move, double-click to reset)", "Ponto zero da régua (arraste para mover, clique duas vezes para redefinir)"), + ("Rulers", "Réguas"), + ("Run", "Executar"), + ("Sample Format: {}", "Formato da amostra: {}"), + ("Samples Per Frame:", "Amostras por quadro:"), + ("Save from #{}", "Salvar a partir de #{}"), + ("Saved {}", "{} salvo"), + ("Script… (⌘/Ctrl+Enter runs)", "Script… (⌘/Ctrl+Enter executa)"), + ("Search commands or shortcuts", "Pesquisar comandos ou atalhos"), + ("Select", "Selecionar"), + ("Select a command to change its shortcut.", "Selecione um comando para alterar seu atalho."), + ("Select a layer to see its effects", "Selecione uma camada para ver seus efeitos"), + ("Select a layer to see its properties", "Selecione uma camada para ver suas propriedades"), + ("({}) Selected Objects", "({}) objetos selecionados"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Selecione uma camada com máscara ou caminho de forma (ou a propriedade de caminho), ou pinos de Puppet.", + ), + ("Select a text layer", "Selecione uma camada de texto"), + ("Select an item in the Project panel", "Selecione um item no painel Projeto"), + ("Select animated properties to show them in the Graph Editor", "Selecione propriedades animadas para exibi-las no Editor de gráficos"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Selecione o pino líder e depois Shift+clique nos pinos que devem segui-lo (cabelo, tecido, caudas)", + ), + ("Set Ground Plane and Origin", "Definir plano do solo e origem"), + ("Set Paint Colors to Black and White (D)", "Definir cores de pintura como preto e branco (D)"), + ("Settings", "Configurações"), + ("Settings Name:", "Nome das configurações:"), + ("Shadow Darkness", "Escuridão da sombra"), + ("Shadow Diffusion", "Difusão da sombra"), + ("Shadows", "Sombras"), + ("Shape…", "Forma…"), + ("Show", "Exibir"), + ("Show Layer Name", "Exibir nome da camada"), + ("Show Source Name", "Exibir nome da origem"), + ("Show me", "Mostre-me"), + ("Show:", "Exibir:"), + ("Shutter Angle:", "Ângulo do obturador:"), + ("Shutter Phase:", "Fase do obturador:"), + ("Size", "Tamanho"), + ("Skip", "Ignorar"), + ("Snapping", "Ajuste magnético"), + ("Snapping options", "Opções de ajuste magnético"), + ("Snapshot", "Instantâneo"), + ("Solo Supported Properties", "Solo de propriedades suportadas"), + ("Solo Switches: {}", "Chaves de solo: {}"), + ("Sort", "Ordenar"), + ("Spacing:", "Espaçamento:"), + ("Spatial Interpolation:", "Interpolação espacial:"), + ("Speed:", "Velocidade:"), + ("Start Timecode:", "Timecode inicial:"), + ("Start: {} Duration: {}", "Início: {} Duração: {}"), + ("Step {} of {}: {}", "Etapa {} de {}: {}"), + ("Stop {} at {}°", "Parada {} em {}°"), + ("Stored in: {}", "Armazenado em: {}"), + ("Stretch", "Esticar"), + ("Stretch Factor:", "Fator de esticamento:"), + ("Stylistic Sets", "Conjuntos estilísticos"), + ("Subpixel Positioning", "Posicionamento em subpixel"), + ("Swap Paint Colors (X)", "Trocar cores de pintura (X)"), + ("System Default", "Padrão do sistema"), + ("Table", "Tabela"), + ("Template name", "Nome do modelo"), + ("Template: {}\n{}", "Modelo: {}\n{}"), + ("Temporal Interpolation:", "Interpolação temporal:"), + ("The Media Browser is not available here (use File ▸ Import)", "O Media Browser não está disponível aqui (use Arquivo ▸ Importar)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "A camada OpenEXR exibida em vermelho, verde, azul e alfa"), + ("The render queue is empty.", "A fila de renderização está vazia."), + ("This ScriptUI panel has closed", "Este painel ScriptUI foi fechado"), + ("This change will create a new solid for this layer.", "Esta alteração criará um novo sólido para esta camada."), + ("Time", "Tempo"), + ("Time Stretch…", "Esticamento de tempo…"), + ("Time:", "Tempo:"), + ("Time: {}", "Tempo: {}"), + ("Title/Action Safe", "Área segura de título/ação"), + ("Title/action safe", "Área segura de título/ação"), + ("Toggle Switches / Modes", "Alternar chaves / modos"), + ("Track Name:", "Nome da faixa:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Os modelos treinados estão disponíveis no aplicativo para desktop; o navegador usa o mecanismo clássico.", + ), + ("Transform", "Transformar"), + ("Transparency grid", "Grade de transparência"), + ("Tutorial complete", "Tutorial concluído"), + ("Two-Node Camera", "Câmera de dois nós"), + ("Type", "Tipo"), + ("Type a command or effect…", "Digite um comando ou efeito…"), + ("URL:", "URL:"), + ("Undock Panel", "Desencaixar painel"), + ("Unlink {}", "Desvincular {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Use esta opção para colocar as camadas selecionadas juntas em uma nova composição intermediária.", + ), + ("VR Environments", "Ambientes de RV"), + ("Value:", "Valor:"), + ("Variable Font Axes", "Eixos da fonte variável"), + ("Video Codec: {}", "Codec de vídeo: {}"), + ("View:", "Exibição:"), + ("Welcome to EffectCraft", "Boas-vindas ao EffectCraft"), + ("Width:", "Largura:"), + ("You can undo this.", "Você pode desfazer isto."), + ("Your changes will be lost if you don't save them.", "Suas alterações serão perdidas se você não as salvar."), + ("Zoom", "Zoom"), + ("built-in", "integrado"), + ("frames per second", "quadros por segundo"), + ("template name", "nome do modelo"), + ("{} contributors · {} commits", "{} colaboradores · {} commits"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB por quadro de 8 bpc"), + ("{} — coming soon", "{} em breve"), + ("{} — double-click to open, {}-click to reveal", "{}: clique duas vezes para abrir, {}+clique para revelar"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} é usado por:"), +]; + +/// Czech (cs): original translations covering every row in the Chinese panel catalogs. +const CZECH: &[(&str, &str)] = &[ + (" OK ", " OK "), + (" Delete ", " Odstranit "), + (" Open Auto-Save ", " Otevřít automatickou zálohu "), + ("(missing)", "(chybí)"), + ("360 View Orientation", "Orientace zobrazení 360°"), + ("3:2 Pulldown: {}", "Převod 3:2 pulldown: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompoziční nástroj s otevřeným zdrojovým kódem pro pohyblivou grafiku a vizuální efekty, vyvinutý nezávisle metodou clean-room. Běží nativně na macOS, Windows a Linuxu i v prohlížeči. Patří do rodiny kreativních aplikací ArtCraft.", + ), + ("About EffectCraft", "O aplikaci EffectCraft"), + ("About...", "O aplikaci…"), + ("Active", "Aktivní"), + ("Active Camera", "Aktivní kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Přizpůsobovat sledovaný prvek v každém snímku"), + ("Adaptive Sample Limit:", "Limit adaptivního vzorkování:"), + ("Add Animator", "Přidat animátor"), + ("Add Comment", "Přidat komentář"), + ("Add Formatting", "Přidat formátování"), + ("Add Group", "Přidat skupinu"), + ("Add Mirror", "Přidat zrcadlo"), + ("Add Selected As", "Přidat vybrané jako"), + ("Add a comment", "Přidat komentář"), + ("Add a shape, fill, stroke or path operation", "Přidat tvar, výplň, tah nebo operaci s cestou"), + ("Add to Composition", "Přidat do kompozice"), + ("Add to Favorites", "Přidat do oblíbených"), + ("Add:", "Přidat:"), + ("Adjust Exposure (stops)", "Upravit expozici (v expozičních stupních)"), + ("Adjust composition duration to the time span of the selected layers", "Přizpůsobit délku kompozice časovému rozsahu vybraných vrstev"), + ("Advanced 3D", "Pokročilé 3D"), + ("Align Layers to:", "Zarovnat vrstvy k:"), + ("All six face cameras turn together; the scene is not changed.", "Všech šest kamer snímajících stěny se otáčí společně; scéna se nemění."), + ("Anchor:", "Kotevní bod:"), + ("Aperture", "Clona"), + ("Appearance Mode", "Režim vzhledu"), + ("Appearance Mode: {}", "Režim vzhledu: {}"), + ("Apply Dimensions:", "Použít rozměry:"), + ("Apply Motion To:", "Použít pohyb na:"), + ("Arrange", "Uspořádat"), + ("Assign", "Přiřadit"), + ("Auto", "Automaticky"), + ("Auto-saves", "Automatické zálohy"), + ("Back", "Zpět"), + ("Background Color:", "Barva pozadí:"), + ("Blend Mode:", "Režim prolnutí:"), + ("Blending Mode", "Režim prolnutí"), + ("Blur", "Rozostření"), + ("Blur Level", "Úroveň rozostření"), + ("Boundary", "Hranice"), + ("Browser Storage", "Úložiště prohlížeče"), + ("Browse…", "Procházet…"), + ("Brush Dynamics", "Dynamika štětce"), + ("Caching frames…", "Ukládání snímků do mezipaměti…"), + ("Cancel", "Zrušit"), + ("Casts Shadows", "Vrhat stíny"), + ("Change Renderer…", "Změnit vykreslovací modul…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Změna zkratky vytvoří vlastní přednastavení; výchozí přednastavení zůstane zachované.", + ), + ("Channel", "Kanál"), + ("Channel:", "Kanál:"), + ("Chapter:", "Kapitola:"), + ("Choose...", "Vybrat…"), + ("Classic 3D", "Klasické 3D"), + ("Clear", "Vymazat"), + ("Clear list", "Vymazat seznam"), + ("Click a key to see its commands.", "Kliknutím na klávesu zobrazíte její příkazy."), + ("Click adds a point (½ height = no change)", "Kliknutím přidáte bod (½ výšky = beze změny)"), + ("Click adds a point; drag one off to remove", "Kliknutím přidáte bod; přetažením mimo jej odstraníte"), + ("Click to make this viewer active", "Kliknutím aktivujete tento prohlížeč"), + ("Click to reveal the expression in the timeline", "Kliknutím zobrazíte výraz na časové ose"), + ("Click to show the active composition here", "Kliknutím zde zobrazíte aktivní kompozici"), + ("Click, then click in the Composition panel", "Klikněte sem a poté do panelu Kompozice"), + ("Clone Options", "Možnosti klonování"), + ("Clone Source Overlay:", "Překrytí zdroje klonování:"), + ("Close", "Zavřít"), + ("Close Panel", "Zavřít panel"), + ("Close tutorial", "Zavřít výukovou lekci"), + ("Color", "Barva"), + ("Color Depth: {}", "Barevná hloubka: {}"), + ("Color:", "Barva:"), + ("Colour map", "Barevná mapa"), + ("Columns", "Sloupce"), + ("Comment:", "Komentář:"), + ("Community", "Komunita"), + ("Composition", "Kompozice"), + ("Composition Mini-Flowchart", "Malý vývojový diagram kompozice"), + ("Composition Name:", "Název kompozice:"), + ("Composition Settings ▸ 3D Renderer", "Nastavení kompozice ▸ 3D vykreslovací modul"), + ("Composition Settings…", "Nastavení kompozice…"), + ("Composition marker bin: drag a marker to the time ruler", "Zásobník značek kompozice: přetáhněte značku na časové pravítko"), + ("Cone Angle", "Úhel kužele"), + ("Cone Feather", "Prolnutí okrajů kužele"), + ("Constrain Proportions", "Zachovat proporce"), + ("Continuous (Lock Outgoing to Incoming)", "Spojitá (svázat výstupní tečnu se vstupní)"), + ("Cue Point", "Bod události"), + ("Current Render", "Aktuální vykreslování"), + ("Dark", "Tmavý"), + ("Dark Theme", "Tmavý motiv"), + ("Darker", "Tmavší"), + ("Default Output:", "Výchozí výstup:"), + ("Default: {}", "Výchozí: {}"), + ("Defaults", "Výchozí hodnoty"), + ("Delete", "Odstranit"), + ("Delete Selected Points", "Odstranit vybrané body"), + ("Delete selected project items", "Odstranit vybrané položky projektu"), + ("Delete this template", "Odstranit tuto šablonu"), + ("Depth of Field", "Hloubka ostrosti"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmístit vrstvy:"), + ("Don't Recover", "Neobnovovat"), + ("Don't Save", "Neukládat"), + ("Double-click a layer to open it in the Layer panel", "Dvojklikem otevřete vrstvu v panelu Vrstva"), + ("Double-click footage in the Project panel to open it here", "Dvojklikem na záznam v panelu Projekt jej otevřete zde"), + ("Double-click to edit the comment", "Dvojklikem upravíte komentář"), + ("Download", "Stáhnout"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Přetáhněte sem vlastnosti z časové osy,\nnebo použijte Animace > Přidat vlastnost do základní grafiky.", + ), + ("Drag to change", "Tažením změníte hodnotu"), + ("Drag to create a guide", "Tažením vytvoříte vodítko"), + ("Drag to slip the source", "Tažením posunete zdroj v čase"), + ("Drop Frame", "Vynechávat čísla snímků"), + ("Duplicate", "Duplikovat"), + ("Duration:", "Délka trvání:"), + ("Edit Gradient…", "Upravit přechod…"), + ("Edit Target: {}", "Upravit cíl: {}"), + ("Edit in the Timeline", "Upravit na časové ose"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), + ("Effects & Presets options", "Možnosti efektů a přednastavení"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Prázdná složka"), + ("Enable Depth of Field", "Povolit hloubku ostrosti"), + ("Enable Expression", "Povolit výraz"), + ("Enhance", "Vylepšit"), + ("Essential Properties — {}", "Základní vlastnosti — {}"), + ("Event", "Událost"), + ("Export Template…", "Exportovat šablonu…"), + ("Export...", "Exportovat…"), + ("Expression Language Menu", "Nabídka jazyka výrazů"), + ("Expression pick whip: drag onto a property", "Propojovací nástroj výrazů: přetáhněte na vlastnost"), + ("Expression:", "Výraz:"), + ("Eyedropper: click, then click in the Composition panel", "Kapátko: klikněte sem a poté do panelu Kompozice"), + ("Falloff", "Útlum"), + ("Falloff Distance", "Vzdálenost útlumu"), + ("Favorites", "Oblíbené"), + ("Field Render: {}", "Vykreslování půlsnímků: {}"), + ("Figures", "Číslice"), + ("Fill Target", "Cíl výplně"), + ("Finished", "Dokončeno"), + ("Flowchart: {}", "Vývojový diagram: {}"), + ("Focus Distance", "Vzdálenost zaostření"), + ("Follow Feature Rotation and Scale", "Sledovat otočení a měřítko prvku"), + ("Footage: {}", "Záznam: {}"), + ("Format: {}", "Formát: {}"), + ("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"), + ("Frame Blending: {}", "Prolnutí snímků: {}"), + ("Frame Rate:", "Snímková frekvence:"), + ("Frame Render Time {}ms", "Čas vykreslení snímku {}ms"), + ("Frame Target:", "Cílový rámec:"), + ("Freeze", "Zmrazit"), + ("Grab bag", "Mozaika"), + ("Grid", "Mřížka"), + ("Guide Layers: {}", "Pomocné vrstvy: {}"), + ("Guides", "Vodítka"), + ("Height:", "Výška:"), + ("Hide This", "Skrýt toto"), + ("Histogram", "Histogram"), + ("Hold In Place", "Pevný časový bod"), + ("Home", "Domů"), + ("If Confidence is Below", "Pokud je spolehlivost nižší než"), + ("Import", "Importovat"), + ("Import and Add to Composition", "Importovat a přidat do kompozice"), + ("Import...", "Importovat…"), + ("Imported media", "Importovaná média"), + ("Influence:", "Vliv:"), + ("Install from File…", "Nainstalovat ze souboru…"), + ("Intensity", "Intenzita"), + ("Inverted", "Invertovaná"), + ("Join the ArtCraft community on Discord", "Připojit se ke komunitě ArtCraft na Discordu"), + ("Key: {}", "Klávesa: {}"), + ("LEARN", "VÝUKA"), + ("Label", "Štítek"), + ("Label Colors and Names:", "Barvy a názvy štítků:"), + ("Label:", "Štítek:"), + ("Latest auto-save: {}", "Nejnovější automatická záloha: {}"), + ("Layer", "Vrstva"), + ("Layer controls", "Ovládací prvky vrstvy"), + ("Layer {}", "Vrstva {}"), + ("Layer:", "Vrstva:"), + ("Layer: {}", "Vrstva: {}"), + ("Learn", "Výuka"), + ("Leave all attributes in \"{}\"", "Ponechat všechny atributy v „{}“"), + ("Licence: {}", "Licence: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Světlý"), + ("Light Theme", "Světlý motiv"), + ("Light Type", "Typ světla"), + ("Lighter", "Světlejší"), + ("Link Selected Property", "Propojit vybranou vlastnost"), + ("Lock Aspect Ratio to {}", "Zamknout poměr stran na {}"), + ("Lock to Zoom", "Svázat s přiblížením"), + ("Locked: {} — {}", "Zamknuto: {} — {}"), + ("Log:", "Protokol:"), + ("Loop (Trace Path)", "Smyčka (sledovat cestu)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Písma: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Nabídka míry přiblížení"), + ("Make Comp Size", "Nastavit velikost kompozice"), + ("Masks", "Masky"), + ("Maximize Panel", "Maximalizovat panel"), + ("Media Replacement", "Nahrazení médií"), + ("Mesh:", "Síť:"), + ("Diameter:", "Průměr:"), + ("Method Used: {} Average Error: {} pixels", "Použitá metoda: {} Průměrná chyba: {} px"), + ("Models folder: {}", "Složka modelů: {}"), + ("Modifiers:", "Modifikátory:"), + ("More", "Další"), + ("More ArtCraft apps", "Další aplikace ArtCraft"), + ("More tutorials", "Další výukové lekce"), + ("Motion Blur", "Rozostření pohybem"), + ("Motion Blur: {}", "Rozostření pohybem: {}"), + ("Motion Sketch: drag to record", "Náčrt pohybu: tažením zaznamenáte pohyb"), + ("Move Down", "Posunout dolů"), + ("Move Up", "Posunout nahoru"), + ("Move to Project Root", "Přesunout do kořene projektu"), + ("Name", "Název"), + ("Name:", "Název:"), + ("Navigation", "Navigace"), + ("New", "Nový"), + ("New Comp from Selection", "Nová kompozice z výběru"), + ("New Duration:", "Nová délka trvání:"), + ("New composition name:", "Název nové kompozice:"), + ("New from Render Queue item:", "Nová šablona z položky fronty vykreslování:"), + ("New from Template", "Nový ze šablony"), + ("Next", "Další"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Hotovo. Vyberte si další lekci na kartě Výuka na domovské obrazovce."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Zatím nejsou žádná prostředí VR. Vytvořte je přes Kompozice ▸ VR ▸ Vytvořit prostředí VR…", + ), + ("No audio layer selected", "Není vybrána zvuková vrstva"), + ("No background jobs running", "Na pozadí neběží žádné úlohy"), + ("No composition markers", "Kompozice nemá žádné značky"), + ("No contributor data was built into this copy.", "Tato sestava neobsahuje údaje o přispěvatelích."), + ("No device list from this host; the system default output is used.", "Hostitel neposkytuje seznam zařízení; používá se výchozí systémový výstup."), + ("No layer selected", "Není vybrána vrstva"), + ("No matching effects or presets", "Žádné odpovídající efekty ani přednastavení"), + ("No matching fonts", "Žádná odpovídající písma"), + ("No model credits were built into this copy.", "Tato sestava neobsahuje údaje o použitých modelech."), + ("No nested compositions", "Žádné vnořené kompozice"), + ("No shortcut", "Bez zkratky"), + ("No unlocked layer selected", "Není vybrána odemknutá vrstva"), + ("No video", "Žádné video"), + ("None", "Žádné"), + ("Not applied: tick it in the list to add it.", "Nepoužito: pro přidání položku zaškrtněte v seznamu."), + ("Not assigned", "Nepřiřazeno"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Poznámka: Stíny vrhají pouze vrstvy s povolenou volbou Vrhat stíny."), + ("Notify", "Upozornit"), + ("OK", "OK"), + ("Offset:", "Posun:"), + ("On ({}) stop:", "Při zastavení ({}):"), + ("One-Node Camera", "Jednouzlová kamera"), + ("Opacity:", "Krytí:"), + ("Open Composition", "Otevřít kompozici"), + ("Open Last Saved Project", "Otevřít poslední uložený projekt"), + ("Open New Composition", "Otevřít novou kompozici"), + ("Open Output", "Otevřít výstup"), + ("Open a composition to build Essential Graphics", "Otevřete kompozici pro sestavení základní grafiky"), + ("Open a composition to see its scopes", "Otevřete kompozici pro zobrazení měřicích přístrojů"), + ("Open a composition to track motion.", "Otevřete kompozici pro sledování pohybu."), + ("Opus Tuning: {}", "Ladění Opus: {}"), + ("Original Duration:", "Původní délka trvání:"), + ("Output Cycle: {}", "Cyklus výstupu: {}"), + ("Output To:", "Výstup do:"), + ("Overlay", "Překrytí"), + ("Parameter Name:", "Název parametru:"), + ("Parent pick whip: drag onto a layer", "Propojovací nástroj nadřazené vrstvy: přetáhněte na vrstvu"), + ("Pixel Aspect Ratio:", "Poměr stran pixelů:"), + ("Playing on Video Preview", "Přehrávání ve videonáhledu"), + ("Post-Render Action: {}", "Akce po vykreslení: {}"), + ("Preserve frame rate when nested or in render queue", "Zachovat snímkovou frekvenci při vnoření nebo ve frontě vykreslování"), + ("Preserve resolution when nested", "Zachovat rozlišení při vnoření"), + ("Preset", "Přednastavení"), + ("Preset:", "Přednastavení:"), + ("Presets", "Přednastavení"), + ("Previous", "Předchozí"), + ("Primary:", "Hlavní kompozice:"), + ("Process Before Match", "Předzpracování před hledáním shody"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Barevná hloubka projektu: kliknutím přepnete 8/16/32 bpc, kliknutím s Alt otevřete Nastavení projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Propojovací nástroj vlastností: přetáhněte na vlastnost"), + ("Protected Region", "Chráněná oblast"), + ("Proxy Use: {}", "Použití zástupných souborů: {}"), + ("Push All to Comp", "Přenést vše do kompozice"), + ("RGB", "RGB"), + ("Radius", "Poloměr"), + ("Rate Control: {}", "Řízení datového toku: {}"), + ("Recent", "Nedávné"), + ("Recording", "Záznam pohybu"), + ("Remove", "Odstranit"), + ("Rename", "Přejmenovat"), + ("Render: {} ms • UI {} fps{}", "Vykreslení: {} ms • UI {} fps{}"), + ("Renderer:", "Vykreslovací modul:"), + ("Request Persistent Storage", "Požádat o trvalé úložiště"), + ("Reset", "Obnovit"), + ("Reset Labels", "Obnovit štítky"), + ("Reset Preset", "Obnovit přednastavení"), + ("Reset to Default", "Obnovit výchozí hodnoty"), + ("Reset to Saved Layout", "Obnovit uložené rozložení"), + ("Resolution/Down Sample Factor Popup", "Nabídka rozlišení a míry podvzorkování"), + ("Restore Panel Size", "Obnovit velikost panelu"), + ("Reverse the order", "Obrátit pořadí"), + ("Revert All", "Vrátit všechny změny"), + ("Roman Hanging Punctuation", "Předsazená interpunkce latinky"), + ("Roving:", "Plovoucí v čase:"), + ("Ruler zero point (drag to move, double-click to reset)", "Nulový bod pravítka (tažením posunete, dvojklikem obnovíte)"), + ("Rulers", "Pravítka"), + ("Run", "Spustit"), + ("Sample Format: {}", "Formát vzorků: {}"), + ("Samples Per Frame:", "Vzorků na snímek:"), + ("Save from #{}", "Uložit z #{}"), + ("Saved {}", "Uloženo {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skript… (⌘/Ctrl+Enter spustí)"), + ("Search commands or shortcuts", "Hledat příkazy nebo zkratky"), + ("Select", "Vybrat"), + ("Select a command to change its shortcut.", "Vyberte příkaz pro změnu jeho zkratky."), + ("Select a layer to see its effects", "Vyberte vrstvu pro zobrazení jejích efektů"), + ("Select a layer to see its properties", "Vyberte vrstvu pro zobrazení jejích vlastností"), + ("({}) Selected Objects", "Vybrané objekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Vyberte vrstvu s cestou masky či tvaru (nebo vlastnost cesty), případně čepy nástroje Loutka.", + ), + ("Select a text layer", "Vyberte textovou vrstvu"), + ("Select an item in the Project panel", "Vyberte položku v panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Vyberte animované vlastnosti pro zobrazení v editoru grafů"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Vyberte řídicí čep a potom se Shiftem klikněte na čepy, které jej mají následovat (vlasy, látka, ocasy)", + ), + ("Set Ground Plane and Origin", "Nastavit základní rovinu a počátek"), + ("Set Paint Colors to Black and White (D)", "Nastavit barvy malování na černou a bílou (D)"), + ("Settings", "Nastavení"), + ("Settings Name:", "Název nastavení:"), + ("Shadow Darkness", "Tmavost stínu"), + ("Shadow Diffusion", "Rozptýlení stínu"), + ("Shadows", "Stíny"), + ("Shape…", "Tvar…"), + ("Show", "Zobrazit"), + ("Show Layer Name", "Zobrazit název vrstvy"), + ("Show Source Name", "Zobrazit název zdroje"), + ("Show me", "Ukázat postup"), + ("Show:", "Zobrazit:"), + ("Shutter Angle:", "Úhel závěrky:"), + ("Shutter Phase:", "Fáze závěrky:"), + ("Size", "Velikost"), + ("Skip", "Přeskočit"), + ("Snapping", "Přichytávání"), + ("Snapping options", "Možnosti přichytávání"), + ("Snapshot", "Snímek náhledu"), + ("Solo Supported Properties", "Zobrazit pouze podporované vlastnosti"), + ("Solo Switches: {}", "Přepínače sólo: {}"), + ("Sort", "Seřadit"), + ("Spacing:", "Rozestupy:"), + ("Spatial Interpolation:", "Prostorová interpolace:"), + ("Speed:", "Rychlost:"), + ("Start Timecode:", "Počáteční časový kód:"), + ("Start: {} Duration: {}", "Začátek: {} Délka trvání: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zastavit"), + ("Stop {} at {}°", "Bod přechodu {} na {}°"), + ("Stored in: {}", "Uloženo v: {}"), + ("Stretch", "Roztažení"), + ("Stretch Factor:", "Koeficient roztažení:"), + ("Stylistic Sets", "Stylistické sady"), + ("Subpixel Positioning", "Subpixelové umístění"), + ("Swap Paint Colors (X)", "Prohodit barvy malování (X)"), + ("Sync with System", "Synchronizovat se systémem"), + ("System Default", "Výchozí systémové"), + ("Table", "Tabulka"), + ("Template name", "Název šablony"), + ("Template: {}\n{}", "Šablona: {}\n{}"), + ("Temporal Interpolation:", "Časová interpolace:"), + ("The Media Browser is not available here (use File ▸ Import)", "Prohlížeč médií zde není k dispozici (použijte Soubor ▸ Importovat)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Vrstva OpenEXR zobrazená v červeném, zeleném, modrém a alfa kanálu"), + ("The render queue is empty.", "Fronta vykreslování je prázdná."), + ("This ScriptUI panel has closed", "Tento panel ScriptUI byl zavřen"), + ("This change will create a new solid for this layer.", "Tato změna vytvoří pro tuto vrstvu novou plnou vrstvu."), + ("Time", "Čas"), + ("Time Stretch…", "Roztažení času…"), + ("Time:", "Čas:"), + ("Time: {}", "Čas: {}"), + ("Title/Action Safe", "Bezpečné oblasti titulků a akce"), + ("Title/action safe", "Bezpečné oblasti titulků a akce"), + ("Toggle Switches / Modes", "Přepnout přepínače / režimy"), + ("Track Name:", "Název stopy:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Trénované modely jsou dostupné v desktopové aplikaci; prohlížeč používá klasické zpracování.", + ), + ("Transform", "Transformace"), + ("Transparency grid", "Mřížka průhlednosti"), + ("Tutorial complete", "Výuková lekce dokončena"), + ("Two-Node Camera", "Dvouzlová kamera"), + ("Type", "Typ"), + ("Type a command or effect…", "Zadejte příkaz nebo efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odpojit panel"), + ("Unfreeze", "Zrušit zmrazení"), + ("Unlink {}", "Odpojit {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Tato volba vloží aktuálně vybrané vrstvy společně do nové mezilehlé kompozice.", + ), + ("VR Environments", "Prostředí VR"), + ("Value:", "Hodnota:"), + ("Variable Font Axes", "Osy variabilního písma"), + ("Video Codec: {}", "Video kodek: {}"), + ("View:", "Zobrazení:"), + ("Welcome to EffectCraft", "Vítejte v EffectCraft"), + ("Width:", "Šířka:"), + ("You can undo this.", "Tuto akci lze vrátit zpět."), + ("Your changes will be lost if you don't save them.", "Pokud změny neuložíte, budou ztraceny."), + ("Zoom", "Přiblížení"), + ("built-in", "vestavěné"), + ("frames per second", "snímků za sekundu"), + ("template name", "název šablony"), + ("{} contributors · {} commits", "{} přispěvatelů · {} commitů"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB na snímek s 8bpc"), + ("{} — coming soon", "{} — již brzy"), + ("{} — double-click to open, {}-click to reveal", "{} — dvojklikem otevřete, kliknutím s {} zobrazíte umístění"), + ("⌘/Ctrl-drag a pin to record its motion", "Tažením čepu s ⌘/Ctrl zaznamenáte jeho pohyb"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} používají:"), + ("3D Reference Axes", "Referenční osy 3D"), + ("Affect all layers that use this solid", "Ovlivnit všechny vrstvy používající tuto plnou vrstvu"), + ("Choose grid and guide options", "Vybrat možnosti mřížky a vodítek"), + ("Colorize", "Kolorovat"), + ("Custom", "Vlastní"), + ("Custom...", "Vlastní…"), + ("Fit", "Přizpůsobit"), + ("Fit up to 100%", "Přizpůsobit nejvýše do 100 %"), + ("Full", "Plné"), + ("Half", "Poloviční"), + ("Only this layer uses this solid.", "Tuto plnou vrstvu používá pouze tato vrstva."), + ("Proportional Grid", "Proporcionální mřížka"), + ("Quarter", "Čtvrtinové"), + ("Region of Interest", "Oblast zájmu"), + ("Reset Exposure", "Obnovit expozici"), + ("Show Channel and Color Management Settings", "Zobrazit nastavení kanálů a správy barev"), + ("Show Snapshot (hold)", "Zobrazit snímek náhledu (podržet)"), + ("Take Snapshot", "Pořídit snímek náhledu"), + ("Third", "Třetinové"), + ("Toggle Mask and Shape Path Visibility", "Přepnout viditelnost cest masek a tvarů"), + ("Toggle Transparency Grid", "Přepnout mřížku průhlednosti"), + ("16 bits per channel", "16 bitů na kanál"), + ("32 bits per channel (float)", "32 bitů na kanál (plovoucí čárka)"), + ("8 bits per channel", "8 bitů na kanál"), + ("Adaptive Resolution", "Adaptivní rozlišení"), + ("Adobe-style built-in", "Vestavěné ve stylu Adobe"), + ("Blue", "Modrý"), + ("Current Settings", "Aktuální nastavení"), + ("Disable Per-character 3D", "Zakázat 3D pro jednotlivé znaky"), + ("Draft", "Pracovní"), + ("Enable Per-character 3D", "Povolit 3D pro jednotlivé znaky"), + ("Fast Draft", "Rychlé pracovní"), + ("Fast Previews (Draft 3D, Faster Effects)", "Rychlé náhledy (pracovní 3D, rychlejší efekty)"), + ("Green", "Zelený"), + ("Import Image Sequence", "Importovat sekvenci obrázků"), + ("Import PDF / Illustrator File", "Importovat soubor PDF / Illustrator"), + ("Import Photoshop File", "Importovat soubor Photoshop"), + ("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"), + ("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"), + ("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"), + ("Mercury Software Only", "Pouze softwarové zpracování Mercury"), + ("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"), + ("Off (Final Quality)", "Vypnuto (finální kvalita)"), + ("OpenGL (compatibility)", "OpenGL (kompatibilita)"), + ("Opus Bitrate: {} kbps", "Datový tok Opus: {} kbps"), + ("RGB Straight", "RGB bez přednásobení"), + ("Red", "Červený"), + ("Tone map", "Mapování tónů"), + ("Use In-Window Menu Bar on macOS", "Použít nabídku v okně na macOS"), + ("Wireframe", "Drátový model"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (výchozí)"), + ("New Project", "Nový projekt"), + ("Open Project…", "Otevřít projekt…"), + ("Templates", "Šablony"), + ("EffectCraft home page", "Domovská stránka EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft na GitHubu"), + ("Motion graphics and visual effects · version {}", "Pohyblivá grafika a vizuální efekty · verze {}"), + ("New Composition", "Nová kompozice"), + ("Open Demo Project", "Otevřít ukázkový projekt"), + ("Import Footage…", "Importovat záznam…"), + ("Filter recent files", "Filtrovat nedávné soubory"), + ("NAME", "NÁZEV"), + ("OPENED", "OTEVŘENO"), + ("SIZE", "VELIKOST"), + ("KIND", "DRUH"), + ("Your recent files will show here", "Zde se zobrazí vaše nedávné soubory"), + ("Create a new project, or open the demo project to look around.", "Vytvořte nový projekt nebo otevřete ukázkový projekt a prozkoumejte aplikaci."), + ("No recent files match the filter", "Filtru neodpovídají žádné nedávné soubory"), + ("Try another name or folder.", "Zkuste jiný název nebo složku."), + ("Just now", "Právě teď"), + ("Yesterday", "Včera"), + ("{} minute ago", "Před {} minutou"), + ("{} minutes ago", "Před {} minutami"), + ("{} hour ago", "Před {} hodinou"), + ("{} hours ago", "Před {} hodinami"), + ("{} day ago", "Před {} dnem"), + ("{} days ago", "Před {} dny"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animace Lottie"), + ("Project", "Projekt"), + ("Click above the bar to add an opacity stop, below it to add a color stop.", "Kliknutím nad pruh přidáte bod krytí, kliknutím pod něj bod barvy."), + ("Gradient Editor", "Editor přechodu"), + ("Location:", "Umístění:"), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.", + ), + ("Variable fonts only", "Jen proměnná písma"), ]; #[cfg(test)] @@ -2112,7 +3210,34 @@ mod tests { const UNITS: &[&str] = &[" px", "%", "·", "s", "ms", "°", "fps", " bpc", " mm", " s", " %", " pixels"]; fn catalogs() -> Vec<(&'static str, &'static str)> { - JAPANESE.iter().copied().chain(SIMPLIFIED_CHINESE.iter().copied()).chain(TRADITIONAL_CHINESE.iter().copied()).chain(UKRAINIAN.iter().copied()).collect() + JAPANESE + .iter() + .copied() + .chain(SIMPLIFIED_CHINESE.iter().copied()) + .chain(TRADITIONAL_CHINESE.iter().copied()) + .chain(UKRAINIAN.iter().copied()) + .chain(PORTUGUESE.iter().copied()) + .chain(CZECH.iter().copied()) + .collect() + } + + #[test] + fn czech_panels_translate_and_keep_project_content_and_argument_order() { + set_current("cs"); + assert_eq!(tr("Composition Name:"), "Název kompozice:"); + assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); + assert_eq!(tr_args("Template: {}\n{}", &[&"Title", &"My description"]), "Šablona: Title\nMy description"); + assert_eq!(tr_args("Leave all attributes in \"{}\"", &[&"File Čeština"]), "Ponechat všechny atributy v „File Čeština“"); + assert_eq!( + tr_args("{} — double-click to open, {}-click to reveal", &[&"File.ecproj", &"Alt"]), + "File.ecproj — dvojklikem otevřete, kliknutím s Alt zobrazíte umístění" + ); + assert_eq!(tr_args("Proxy Use: {}", &[]), "Použití zástupných souborů: "); + set_current("en"); + assert_eq!(tr("Composition Name:"), "Composition Name:"); } /// A row that translates nothing, repeats a source string, or changes the number of values the @@ -2122,8 +3247,8 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); - // Japanese and Ukrainian may lag the Chinese catalogs: a missing row falls back to English. - for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN)] { + // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. + for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); assert!(extra.is_empty(), "the {name} catalog lists source strings the Chinese ones don't: {extra:?}"); } @@ -2168,6 +3293,19 @@ mod tests { assert_eq!(tr("Composition Name:"), "Composition Name:"); } + /// Brazilian Portuguese panels translate, fill values in order and keep project content. + #[test] + fn brazilian_portuguese_panels_translate_and_keep_project_content() { + set_current("pt-br"); + assert_eq!(tr("Composition Name:"), "Nome da composição:"); + assert_eq!(tr("Cancel"), "Cancelar"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Etapa 1 de 3: Colour"); + assert_eq!(tr(" OK "), " OK "); + set_current("en"); + assert_eq!(tr("Cancel"), "Cancel"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52066f85..976e58ab 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -22,6 +22,7 @@ pub enum Icon { Ellipse, Polygon, Star, + StarFilled, Pen, PenAdd, PenDelete, @@ -145,6 +146,8 @@ impl Pen16<'_> { let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); self.painter.add(PathShape::closed_line(v, PathStroke::new(self.width * self.s(), self.color))); } + /// Fill a **convex** outline: the tessellator fans a concave one into slivers across its + /// notches, so a concave glyph is drawn as overlapping convex pieces (see [`SELECTION_ARROW`]). fn fill(&self, pts: &[(f32, f32)]) { let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); self.painter.add(PathShape::convex_polygon(v, self.color, Stroke::NONE)); @@ -204,6 +207,25 @@ impl Pen16<'_> { } } +/// The convex pieces of each concave filled glyph. Pieces overlap rather than abut: abutting +/// antialiased edges would leave a faint seam. +type Pieces = &'static [&'static [(f32, f32)]]; + +/// The Selection tool's arrow pointer (tip upper left, a notch each side of the tail): the head +/// with its left wing, the right wing, and the tail. Only the head reaches the tip: slivers +/// meeting there would antialias into a spike past it. +const SELECTION_ARROW: Pieces = &[ + &[(4.5, 2.0), (4.5, 13.0), (7.2, 10.5), (10.1, 7.39)], + &[(7.5, 9.0), (8.9, 9.9), (12.5, 9.7), (10.1, 7.39)], + &[(7.8, 8.6), (7.2, 10.5), (9.2, 14.6), (10.8, 13.9), (8.9, 9.9)], +]; +/// A loudspeaker: the magnet box, running a little into the cone. +const SPEAKER: Pieces = &[&[(2.0, 6.0), (5.5, 6.0), (5.5, 10.0), (2.0, 10.0)], &[(5.0, 6.0), (9.0, 2.5), (9.0, 13.5), (5.0, 10.0)]]; +/// A four-pointed star: an upright and a level diamond, whose edges cross at the inner corners. +const SPARKLE: Pieces = &[&[(8.0, 1.5), (9.784, 8.0), (8.0, 14.5), (6.216, 8.0)], &[(1.5, 8.0), (8.0, 6.216), (14.5, 8.0), (8.0, 9.784)]]; +#[cfg(test)] +const CONCAVE: [Pieces; 3] = [SELECTION_ARROW, SPEAKER, SPARKLE]; + /// Paint `icon` centred in `rect`. pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { let pen = Pen16 { painter, rect, color, width: 1.25 }; @@ -213,7 +235,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.closed(&[(2.5, 7.5), (8.0, 2.5), (13.5, 7.5), (13.5, 13.5), (2.5, 13.5)]); pen.rect(6.5, 9.5, 9.5, 13.5); } - Selection => pen.fill(&[(4.5, 2.0), (4.5, 13.0), (7.2, 10.5), (9.2, 14.6), (10.8, 13.9), (8.9, 9.9), (12.5, 9.7)]), + Selection => SELECTION_ARROW.iter().for_each(|p| pen.fill(p)), Hand => { pen.line(&[(5.0, 9.0), (5.0, 4.0)]); pen.line(&[(7.3, 8.0), (7.3, 2.6)]); @@ -271,6 +293,18 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); pen.closed(&pts); } + StarFilled => { + // Five triangles fill the concave star without a convex-polygon approximation. + let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); + for pair in pts.windows(2) { + if let [a, b] = pair { + pen.fill(&[(8.0, 8.6), *a, *b]); + } + } + if let (Some(first), Some(last)) = (pts.first(), pts.last()) { + pen.fill(&[(8.0, 8.6), *last, *first]); + } + } Pen => { pen.closed(&[(8.0, 1.8), (12.0, 9.0), (8.0, 14.0), (4.0, 9.0)]); pen.dot(8.0, 8.6, 1.1); @@ -345,7 +379,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.dot(8.0, 8.0, 2.2); } Speaker => { - pen.fill(&[(2.0, 6.0), (5.0, 6.0), (9.0, 2.5), (9.0, 13.5), (5.0, 10.0), (2.0, 10.0)]); + SPEAKER.iter().for_each(|p| pen.fill(p)); pen.arc(9.0, 8.0, 3.0, -50.0, 50.0); pen.arc(9.0, 8.0, 5.5, -50.0, 50.0); } @@ -621,7 +655,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(9.4, 2.8), (6.6, 13.2)]); } Sparkle => { - pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); + SPARKLE.iter().for_each(|p| pen.fill(p)); } Monitor => { pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2); @@ -727,6 +761,22 @@ fn keyframe_shapes( mod tests { use effectcraft_engine::keyframe::KeyHalf as H; + /// The Selection arrow (and the Speaker and Sparkle) are concave; filled as one polygon they + /// rendered as a broken fan. Each is drawn as convex pieces instead. + #[test] + fn concave_glyphs_are_filled_as_convex_pieces() { + for piece in super::CONCAVE.iter().flat_map(|g| g.iter()) { + let n = piece.len(); + let turns: Vec = (0..n) + .map(|i| { + let (a, b, c) = (piece[i], piece[(i + 1) % n], piece[(i + 2) % n]); + (b.0 - a.0) * (c.1 - b.1) - (b.1 - a.1) * (c.0 - b.0) + }) + .collect(); + assert!(turns.iter().all(|t| *t < 0.0) || turns.iter().all(|t| *t > 0.0), "{piece:?} is not convex: {turns:?}"); + } + } + /// #284: keyframes have no dark border (overlapping keys looked messy): each half is filled /// and edged in the key's own colour. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 914bf487..746d917b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod credits; pub mod dock; mod dock_shared; pub mod dock_ui; +mod expression_diagnostics; pub mod frames; pub mod gpu_failure; pub mod header; @@ -325,6 +326,7 @@ pub struct EffectcraftApp { /// Audio panel VU meters. pub meter: audio::Meter, /// Waveform peak summaries per footage item (see `panels::waveform`). + pub(crate) expression_diagnostics: expression_diagnostics::Diagnostics, pub(crate) waveforms: panels::waveform::Cache, /// Last reveal shortcut and when (double-press shortcuts such as LL). pub(crate) last_reveal: Option<(String, f64)>, @@ -410,6 +412,7 @@ impl EffectcraftApp { audio: None, scrub: None, meter: Default::default(), + expression_diagnostics: Default::default(), waveforms: Default::default(), last_reveal: None, applied_prefs: None, @@ -557,6 +560,25 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// Feed this frame's share of the queued synthetic input (control channel `ui.click`, + /// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves + /// → release as a real gesture. + pub fn feed_synthetic(&mut self, raw_input: &mut egui::RawInput) { + if !self.synthetic.is_empty() { + // Pointer events go one per frame so egui sees press → moves → release as a real drag. + let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); + let n = if pointer(&self.synthetic[0]) { + 1 + } else { + self.synthetic + .iter() + .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) + .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) + }; + raw_input.events.extend(self.synthetic.drain(..n)); + } + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ . pub fn show_panel(&mut self, p: PanelKind) { @@ -823,27 +845,26 @@ impl EffectcraftApp { self.viewer_image.clone() } + /// The viewer's Resolution: the active composition's (saved with the project). + pub fn viewer_res(&self) -> state::Resolution { + self.session.active_comp().map(|c| c.resolution).unwrap_or_default() + } + /// The render scale used by the viewer right now. pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 { // The preview's Resolution (when not Auto) while it plays. if self.playback.playing && let Some(f) = self.playback.res { - let r = match f { - 1 => state::Resolution::Full, - 2 => state::Resolution::Half, - 3 => state::Resolution::Third, - 4 => state::Resolution::Quarter, - n => state::Resolution::Custom(n.min(40) as u8), - }; - return r.scale(zoom, ppp); + return state::Resolution::from_factor(f.into()).scale(zoom, ppp); } // Auto follows the zoom alike while playing and paused, so frames cached while scrubbing // play back, and frames cached by a preview stay on screen when it stops. - let full = self.ui.viewer.res.scale(zoom, ppp); + let res = self.viewer_res(); + let full = res.scale(zoom, ppp); // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); - if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { + if k < 1.0 && res.is_auto() { return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } full @@ -1153,6 +1174,9 @@ impl EffectcraftApp { }; let mut queued = self.frames.inflight(); let series = self.frame_series(cid, scale); + // The preview's position orders prefetch and eviction (#595). + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + self.frames.set_playhead(playhead); if self.playback.caching && let Some(p) = plan { @@ -1193,7 +1217,10 @@ impl EffectcraftApp { return; } let step = plan.map_or(1, |p| p.step); - for k in 0..ahead * 3 { + // Never further ahead than the RAM preview holds: those frames would evict the ones the + // preview shows first, or be evicted before it gets there (#595). + let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1); + for k in 0..window { if queued >= ahead as usize || !video { break; } @@ -1808,19 +1835,7 @@ impl eframe::App for EffectcraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { let injected = !self.synthetic.is_empty(); - if injected { - // Pointer events go one per frame so egui sees press → moves → release as a real drag. - let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); - let n = if pointer(&self.synthetic[0]) { - 1 - } else { - self.synthetic - .iter() - .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) - .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) - }; - raw_input.events.extend(self.synthetic.drain(..n)); - } + self.feed_synthetic(raw_input); if pointer_wrap::prepare_input(ctx, raw_input, injected) { self.session.history.merge_key = None; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8ebd1c3e..b10b0e31 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -35,7 +35,6 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), - uic!("view.res.auto", "Resolution: Auto", [], None), uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")), uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")), uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")), @@ -127,6 +126,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), + uic!("window.panelWindow", "Move Panel to New Window", [], None), ]; pub fn panel_command_id(p: PanelKind) -> String { @@ -273,6 +273,12 @@ fn no_params(p: &Value) -> bool { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of + // the comp, as After Effects does, so typing goes into Source Text rather than running + // shortcuts (#866). `engine.execute` keeps the headless default. + if id == "layer.newText" && no_params(¶ms) { + return run_engine(app, ctx, id, json!({"text": "", "edit": true})); + } if no_params(¶ms) && app.ui.focused == PanelKind::Project { match id { "edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})), @@ -451,6 +457,22 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } return Ok(Value::Null); } + // Multiple monitors: {panel, window?: bool} moves a panel into its own native window (or + // back into the main window; no `window` toggles). + if id == "window.panelWindow" { + // A panel name ("Info", "Effect Controls") or its serialized form ({"Viewer": 2}). + let value = params.get("panel").ok_or("panelWindow: need `panel`")?; + let panel = match value.as_str() { + Some(name) => PanelKind::from_name(name).ok_or(format!("unknown panel `{name}`"))?, + None => serde_json::from_value::(value.clone()).map_err(|_| "panelWindow: unknown `panel`".to_string())?, + }; + let window = match params.get("window") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_bool().ok_or("panelWindow: `window` must be true or false")?), + }; + let window = app.set_panel_window(ctx, panel, window)?; + return Ok(json!({"window": window})); + } // A UI-only command (not in the engine's menus), so `frontend` gets it from here. if id == "window.maximizeApp" { return frontend(app, ctx, id, params); @@ -506,10 +528,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?; return Ok(json!({"dialog": id})); } - if id == "view.res.auto" { - app.ui.viewer.res = Resolution::Auto; - return Ok(Value::Null); - } + if id == "view.theme.toggle" { app.cycle_appearance(ctx); return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode})); @@ -534,11 +553,23 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.stop(); return Ok(Value::Null); } + // The focused Layer panel has its own magnification; otherwise the Composition viewer. + "view.fit" if app.ui.focused == PanelKind::Layer => { + app.ui.layer_zoom = None; + app.ui.layer_pan = [0.0, 0.0]; + return Ok(Value::Null); + } "view.fit" => { app.ui.viewer.zoom = None; + app.ui.viewer.fit_up_to_100 = false; app.ui.viewer.pan = [0.0, 0.0]; return Ok(Value::Null); } + "view.actualSize" if app.ui.focused == PanelKind::Layer => { + app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); + app.ui.layer_pan = [0.0, 0.0]; + return Ok(Value::Null); + } "view.actualSize" => { app.ui.viewer.zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.viewer.pan = [0.0, 0.0]; @@ -870,44 +901,35 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } r } + // Zoom In / Zoom Out act on the focused viewer: the Layer panel when it has focus, else + // the Composition viewer. + "view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => { + let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx)); + // The Layer panel zooms in points (pixels-per-point 1). + let next = zoom_step(cur, 1.0, id == "view.zoomIn"); + app.ui.layer_zoom = Some(next); + json!({"zoom": next, "panel": "layer"}) + } "view.zoomIn" | "view.zoomOut" => { + let ppp = ctx.pixels_per_point(); let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)); - const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; - let next = if id == "view.zoomIn" { - STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0) - } else { - STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015) - }; + let next = zoom_step(cur, ppp, id == "view.zoomIn"); v.zoom = Some(next); json!({"zoom": next}) } - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - let r = id.trim_start_matches("view.res."); - v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?; - Value::Null - } + // View ▸ Resolution ▸ Custom without a factor (the engine sets the comp's resolution). "view.res.custom" => { - match p.get("factor").and_then(Value::as_u64) { - Some(n) => { - v.res = match n.clamp(1, 40) { - 1 => Resolution::Full, - n => Resolution::Custom(n as u8), - }; - } - None => { - let cur = match v.res { - Resolution::Custom(n) => n as f64, - _ => 2.0, - }; - crate::panels::dialogs::form( - app, - "Custom Resolution", - "view.res.custom", - json!({}), - vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], - ); - } - } + let cur = match app.viewer_res() { + Resolution::Custom(n) => f64::from(n), + _ => 2.0, + }; + crate::panels::dialogs::form( + app, + "Custom Resolution", + "view.res.custom", + json!({}), + vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], + ); Value::Null } "view.rulers" => toggle(&mut v.rulers, &p), @@ -1096,6 +1118,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "layer.openLayer" => { let id = p.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0)); + // Another layer opens at Fit. + if id != app.ui.layer_panel { + app.ui.layer_zoom = None; + app.ui.layer_pan = [0.0, 0.0]; + } app.ui.layer_panel = id; app.ui.layer_view = None; app.show_panel(PanelKind::Layer); @@ -1372,6 +1399,18 @@ pub(crate) fn entry_shortcut(app: &EffectcraftApp, e: &MenuEntry) -> Option bool { + if app.ui.floating.iter().any(|f| f.panels.get(f.active) == Some(&p)) { + return true; + } + match app.ui.maximized.filter(|m| app.ui.dock.contains(*m)) { + Some(m) => m == p, + None => app.ui.dock.is_visible(p), + } +} + /// Check-mark state of frontend toggles (engine state is answered by the engine). pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option { let v = &app.ui.viewer; @@ -1386,10 +1425,9 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option "view.layerControls" => Some(v.show_layer_controls), "playback.cacheWhenIdle" => Some(app.ui.cache_when_idle), "playback.audio" => Some(app.session.prefs.preview.active().include_audio), - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res."))) - } "window.workspace" => Some(pstr("name").is_some_and(|n| n == app.ui.workspace)), + // The Tools panel is the header toolbar: always on screen. + "window.panel" => Some(pstr("panel").is_some_and(|n| n.eq_ignore_ascii_case("tools") || PanelKind::from_name(n).is_some_and(|p| panel_shown(app, p)))), "view.panelBackground" if e.params.get("pick").is_none() => { let cur = match v.pasteboard { None => "mediumGray", @@ -1548,8 +1586,8 @@ pub type Binding = (egui::Modifiers, egui::Key, String, Value); pub fn bindings(session: &effectcraft_engine::Session) -> Vec { let mut v: Vec = Vec::new(); for (sc, b) in session.shortcuts().bindings() { - // Off macOS `Cmd` is Ctrl, so a Ctrl+Cmd shortcut (Enter Full Screen's Ctrl+Cmd+F) would - // steal the plain Cmd one (Find's Cmd+F): such macOS-only shortcuts aren't bound there. + // Off macOS `Cmd` is Ctrl, so a custom Ctrl+Cmd shortcut would + // steal the plain Cmd one: such macOS-only shortcuts aren't bound there. if !cfg!(target_os = "macos") && sc.split('+').any(|p| p == "Ctrl") && sc.split('+').any(|p| p == "Cmd") { continue; } @@ -1796,7 +1834,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let mut nav = crate::menu_keys::Nav::default(); ui.horizontal_centered(|ui| { ui.add_space(6.0); - egui::MenuBar::new().ui(ui, |ui| { + egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| { for node in effectcraft_engine::menus::menu_bar() { if let MenuNode::Submenu { label, children } = node { let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { @@ -1958,10 +1996,38 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::R ui.add_enabled(enabled, b) } +/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so +/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is +/// in points per comp pixel like the result (#882). +fn zoom_step(cur: f32, ppp: f32, zoom_in: bool) -> f32 { + const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let shown = cur * ppp; + let next = if zoom_in { + STEPS.iter().copied().find(|s| *s > shown + 1e-3).unwrap_or(32.0) + } else { + STEPS.iter().rev().copied().find(|s| *s < shown - 1e-3).unwrap_or(0.015) + }; + next / ppp +} + #[cfg(test)] mod tests { use super::*; + /// #882: at 125 % / 150 % display scaling Zoom In reaches 100 % (one comp pixel per screen + /// pixel), not 100 % in points (125 % / 150 % on screen). + #[test] + fn zoom_steps_land_on_whole_screen_magnifications() { + for ppp in [1.0f32, 1.25, 1.5, 2.0] { + let at_100 = zoom_step(0.7 / ppp, ppp, true); + assert!((at_100 * ppp - 1.0).abs() < 1e-6, "ppp {ppp}: {}", at_100 * ppp); + assert!((zoom_step(at_100, ppp, true) * ppp - 1.5).abs() < 1e-6); + assert!((zoom_step(at_100, ppp, false) * ppp - 0.66).abs() < 1e-6); + } + assert!(zoom_step(1.0, 0.0, true).is_finite()); + } + #[test] fn shortcut_passes_share_frame_start_modifiers() { let ctx = egui::Context::default(); @@ -1997,6 +2063,18 @@ mod tests { } } + #[test] + fn full_screen_default_is_platform_specific() { + let app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let full_screen = app.session.shortcuts().shortcut_of("view.fullScreen", &Value::Null); + if cfg!(target_os = "macos") { + assert_eq!(full_screen, Some("Ctrl+Cmd+F")); + } else { + assert_eq!(full_screen, None); + assert_eq!(app.session.shortcuts().shortcut_of("window.maximizeApp", &Value::Null), Some("Cmd+\\")); + } + } + #[test] fn ui_and_menu_shortcuts_do_not_collide() { let app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e727b9f9..5d0a3c53 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -196,6 +196,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Servicios" } else if crate::i18n::brazilian_portuguese(app) { "Serviços" + } else if crate::i18n::czech(app) { + "Služby" } else { "Services" } @@ -314,8 +316,9 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); - v.res.label().hash(&mut h); (app.ui.cache_when_idle, app.session.prefs.preview.active().include_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); + // Window menu check marks: the panels on screen. + crate::dock::PanelKind::ALL.iter().filter(|p| crate::menus::panel_shown(app, **p)).for_each(|p| p.hash(&mut h)); h.finish() } @@ -345,6 +348,17 @@ fn text_field_event(command: &str, clipboard: Option) -> Option Result { let item = menu.find(id).ok_or_else(|| format!("no native menu item `{id}`"))?; let (command, params) = (item.command.clone(), item.params.clone()); + // The menu bar takes Cmd+D and Cmd+R before the window sees them: while a confirmation that + // answers to the key is open, hand it back. Choosing the item with the mouse (no Cmd held) + // still runs it. + if let Some(key) = crate::panels::dialogs::command_key(app) + && item.accelerator.as_deref().is_some_and(|a| ["Cmd+", "Ctrl+"].iter().any(|m| a.strip_prefix(m) == Some(key.name()))) + && ctx.input(|i| i.modifiers.command) + { + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + ctx.request_repaint(); + return Ok(Value::Null); + } if ctx.egui_wants_keyboard_input() { let clip = if command == "edit.paste" { app.hooks.clipboard_text.as_ref().and_then(|f| f()) } else { None }; if let Some(events) = text_field_event(&command, clip) { @@ -445,6 +459,27 @@ mod tests { assert_eq!(translated, ["Служби", "Приховати EffectCraft", "Приховати решту", "Показати все"]); } + #[test] + fn czech_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); + } + #[test] fn hide_items_are_predefined_on_macos() { let spec = build(&app()); @@ -469,6 +504,32 @@ mod tests { } } + #[test] + fn a_confirmation_gets_its_command_key_back_from_the_menu_bar() { + let mut app = app(); + let spec = build(&app); + let dup = spec.items().into_iter().find(|i| i.command == "edit.duplicate").map(|i| i.id.clone()).unwrap(); + let ctx = egui::Context::default(); + app.dialog_state.delete_items.command = Some((json!({"items": []}), json!({"items": 1, "layers": 1, "comps": 1}))); + app.dialog = Some(crate::Dialog::DeleteItems); + // Chosen with the mouse, Duplicate still runs. + ctx.run_ui(Default::default(), |ui| { + let _ = activate(&mut app, ui.ctx(), &spec, &dup); + }) + .textures_delta + .clear(); + assert!(app.synthetic.is_empty()); + // Cmd+D goes to the prompt instead. + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + ctx.run_ui(raw, |ui| { + let _ = activate(&mut app, ui.ctx(), &spec, &dup); + }) + .textures_delta + .clear(); + assert!(matches!(app.synthetic.as_slice(), [egui::Event::Key { key: egui::Key::D, pressed: true, .. }])); + } + #[test] fn accelerators_convert() { assert_eq!(accelerator("Cmd+Shift+S").as_deref(), Some("Cmd+Shift+S")); @@ -495,6 +556,28 @@ mod tests { } } + /// Window menu entries are checked while their panel is on screen. + #[test] + fn window_menu_checks_the_panels_on_screen() { + use crate::dock::PanelKind; + let mut app = app(); + let checked = |app: &EffectcraftApp, panel: &str| { + build(app).items().into_iter().find(|i| i.command == "window.panel" && i.params["panel"] == panel).and_then(|i| i.checked) + }; + assert_eq!(checked(&app, "timeline"), Some(true)); + assert_eq!(checked(&app, "tools"), Some(true)); + assert_eq!(checked(&app, "renderQueue"), Some(false)); + let k0 = state_key(&app); + app.show_panel(PanelKind::RenderQueue); + assert_ne!(state_key(&app), k0); + // A tab behind another one, a closed panel and panels under a maximized one are not shown. + assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(true), Some(false))); + app.ui.dock.close(PanelKind::RenderQueue); + assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(false), Some(true))); + app.ui.maximized = Some(PanelKind::Timeline); + assert_eq!((checked(&app, "timeline"), checked(&app, "project")), (Some(true), Some(false))); + } + #[test] fn state_changes_flow_into_the_spec() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 71cea155..2bccfae0 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -296,7 +296,7 @@ pub fn viewer_hook( ui.data_mut(|d| d.insert_temp(cid, pts)); } let base = json!({"layer": a.layer.0, "effect": a.uid}); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let pts: Vec = ui.data(|d| d.get_temp(cid)).unwrap_or_default(); let with = |kind: Option<&str>, multiple: bool| { let mut q = base.clone(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index ca003c3b..2cd19df5 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -3,6 +3,8 @@ //! (`dialog.comp.`). use crate::i18n::{tr, tr_args}; +use crate::icons::Icon; +use effectcraft_engine::prefs::CompPreset; use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode}; use egui::{Color32, vec2}; use serde_json::json; @@ -96,6 +98,8 @@ pub struct CompDraft { /// Timecode text being edited (start, duration). pub start_tc: Option, pub dur_tc: Option, + /// The name being typed for Save Preset (the name row shows while `Some`). + pub preset_name: Option, } impl Default for CompDraft { @@ -121,6 +125,7 @@ impl Default for CompDraft { tab: Tab::Basic, start_tc: None, dur_tc: None, + preset_name: None, } } } @@ -171,12 +176,84 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, r r } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. -pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registry) -> Option { +fn name_focus_id() -> egui::Id { + egui::Id::new("comp-preset-name-focus") +} + +/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while +/// `d.preset_name` is `Some`): OK or Enter saves the draft's frame size, pixel aspect and frame +/// rate under the name, replacing a saved preset with that name; Cancel or Escape closes it. +pub fn choose_name( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) { + let Some(mut name) = d.preset_name.take() else { return }; + let mut keep = true; + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(ui.available_width())); + auto.add("dialog.comp.presetName", r.rect, "Preset Name"); + if ui.data_mut(|m| m.remove_temp::(name_focus_id())).unwrap_or(false) { + r.request_focus(); + } + let trimmed = name.trim(); + let builtin = PRESETS.iter().any(|p| p.0 == trimmed); + let exists = custom.iter().any(|q| q.name == trimmed); + let ok = !trimmed.is_empty() && !builtin && trimmed.chars().count() <= CompPreset::MAX_NAME; + let note = if builtin { + tr("A built-in preset has this name") + } else if exists { + tr("Replaces your preset with this name") + } else { + "" + }; + ui.add_space(4.0); + ui.label(egui::RichText::new(note).color(Color32::GRAY)); + ui.add_space(8.0); + let mut save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add_enabled(ok, egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + auto.add("dialog.comp.presetNameSave", b.rect, "OK"); + save |= b.clicked(); + let c = ui.button(tr("Cancel")); + auto.add("dialog.comp.presetNameCancel", c.rect, "Cancel"); + cancel |= c.clicked(); + }); + if ok && save { + cmds.push(("comp.savePreset", json!({"name": trimmed, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps}))); + keep = false; + } + if cancel { + keep = false; + } + if keep { + d.preset_name = Some(name); + } +} + +/// Whether the draft's frame size, pixel aspect and frame rate are those of a preset. +fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { + w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) -> Option { let mut out = None; ui.horizontal(|ui| { ui.label(tr("Composition Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); auto.add("dialog.comp.name", r.rect, "Composition Name"); }); ui.add_space(8.0); @@ -195,19 +272,48 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); - let current = PRESETS - .iter() - .find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3) - .map(|p| p.0) + // The user's own presets win over a built-in one with the same values. + let own = custom.iter().find(|q| matches(d, q.width, q.height, q.pixel_aspect, q.frame_rate)); + let current = own + .map(|q| q.name.as_str()) + .or_else(|| PRESETS.iter().find(|(_, w, h, par, f)| matches(d, *w, *h, *par, *f)).map(|p| p.0)) .unwrap_or("Custom"); - let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { - for (name, w, h, par, f) in PRESETS { - if ui.selectable_label(*name == current, *name).clicked() { - (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + ui.horizontal(|ui| { + let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { + // Your own presets first, then the built-in ones. + for q in custom { + if ui.selectable_label(q.name == current, &q.name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (q.width, q.height, q.pixel_aspect, q.frame_rate); + } + } + if !custom.is_empty() { + ui.separator(); + } + for (name, w, h, par, f) in PRESETS { + if ui.selectable_label(*name == current, *name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + } + } + }); + auto.add("dialog.comp.preset", r.response.rect, "Preset"); + let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save")) + .on_hover_text(tr("Save Preset…")); + auto.add("dialog.comp.presetSave", save_r, "Save Preset"); + if save.clicked() { + d.preset_name = Some(own.map(|q| q.name.clone()).unwrap_or_default()); + ui.data_mut(|m| m.insert_temp(name_focus_id(), true)); + } + if let Some(q) = own { + let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete")) + .on_hover_text(tr("Delete Preset")); + auto.add("dialog.comp.presetDelete", del_r, "Delete Preset"); + if del.clicked() { + cmds.push(("comp.deletePreset", json!({"name": q.name}))); } } }); - auto.add("dialog.comp.preset", r.response.rect, "Preset"); ui.end_row(); ui.label(tr("Width:")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/delete_items.rs b/crates/ui-egui/src/panels/delete_items.rs index ca07fd18..b093fb9a 100644 --- a/crates/ui-egui/src/panels/delete_items.rs +++ b/crates/ui-egui/src/panels/delete_items.rs @@ -74,6 +74,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(false); } + // Cmd+D (Ctrl elsewhere) is Delete. + if answer.is_none() && super::dialogs::ui_command_key(ctx, egui::Key::D) { + answer = Some(true); + } let Some(delete) = answer else { return }; app.dialog = None; app.dialog_state.delete_items.command = None; @@ -89,3 +93,22 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { pub fn delete_confirmed(app: &mut EffectcraftApp, ctx: &egui::Context, params: Value) -> Result { crate::menus::run_engine(app, ctx, "project.delete", params) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cmd_d_is_delete_and_enter_is_not() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.dialog_state.delete_items.command = Some((json!({"items": []}), json!({"items": 1, "layers": 1, "comps": 1}))); + app.dialog = Some(Dialog::DeleteItems); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert_eq!(app.dialog, Some(Dialog::DeleteItems)); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::D, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none() && app.dialog_state.delete_items.command.is_none(), "Cmd+D answered Delete"); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8312cbdc..e95a667e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -67,7 +67,8 @@ pub struct DialogState { pub fn open_new_comp(app: &mut EffectcraftApp) { let n = app.session.project.comps().count() + 1; let last = app.dialog_state.last_new_comp.clone().unwrap_or_default(); - app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, ..last }; + app.dialog_state.comp = + CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; app.dialog = Some(Dialog::NewComp); } @@ -157,9 +158,18 @@ fn modal_space(ctx: &egui::Context) -> egui::Vec2 { } pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { + modal_with_scroll(ctx, title, size, t, false, body); +} + +pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { + modal_with_scroll(ctx, title, size, t, true, body); +} + +fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { - egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + let scroll = if vertical_only { egui::ScrollArea::vertical() } else { egui::ScrollArea::both() }; + scroll.max_height(max_height).auto_shrink([false, true]).show(ui, body); }); } @@ -329,10 +339,24 @@ fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) { let mut d = app.dialog_state.comp.clone(); let mut result = None; + let custom = app.session.prefs.comp_presets.clone(); + let mut cmds = Vec::new(); modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto); + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); }); - if result.is_none() && ui_enter(ctx) { + if d.preset_name.is_some() { + // Save Preset asks for the name in its own window over the dialog, as After Effects does. + result = None; + modal(ctx, "Choose Name", vec2(360.0, 150.0), t, |ui| super::comp_settings::choose_name(ui, &mut d, t, &mut app.auto, &custom, &mut cmds)); + } + // Enter that saved a preset name (or while one is typed) doesn't also press OK. + let preset_busy = !cmds.is_empty() || d.preset_name.is_some(); + for (id, params) in cmds { + if let Err(e) = app.session.execute(id, params) { + app.ui.status = e.to_string(); + } + } + if result.is_none() && !preset_busy && ui_enter(ctx) { result = Some(super::comp_settings::params(&d)); } app.dialog_state.comp = d; @@ -352,6 +376,35 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } +/// One frame of the open dialog with `key` pressed. +#[cfg(test)] +pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + let mut out = ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + show(app, ui.ctx()); + }); + out.textures_delta.clear(); +} + +/// The letter that answers the open confirmation with Cmd (Ctrl elsewhere): Cmd+D is Delete in +/// Delete Items, Cmd+R is Revert in Revert Project. The native menu bar hands these keys back +/// while the prompt is open ([`crate::native_menu::activate`]). +pub(crate) fn command_key(app: &EffectcraftApp) -> Option { + match app.dialog? { + Dialog::DeleteItems => Some(egui::Key::D), + Dialog::UnsavedChanges if super::unsaved::is_revert(app) => Some(egui::Key::R), + _ => None, + } +} + +/// Cmd+`key` (Ctrl elsewhere) pressed, also while a text field has focus: ask before the dialog +/// is drawn, so the field does not edit with the key first. +pub(crate) fn ui_command_key(ctx: &egui::Context, key: egui::Key) -> bool { + ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, key)) +} + pub(crate) fn ui_enter(ctx: &egui::Context) -> bool { !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) } @@ -371,6 +424,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &name); auto.add("dialog.solid.name", r.rect, "Name"); }); ui.add_space(8.0); @@ -646,6 +700,32 @@ mod tests { assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default"); } + #[test] + fn a_dialog_opens_with_its_name_field_focused_and_selected() { + let (mut app, ctx) = app(); + let selected = |ctx: &egui::Context| { + let id = ctx.memory(|m| m.focused())?; + egui::TextEdit::load_state(ctx, id)?.cursor.char_range().map(|r| r.as_sorted_char_range()).map(|r| r.start.0..r.end.0) + }; + open_new_comp(&mut app); + let name = app.dialog_state.comp.name.clone(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..name.chars().count()), "typing replaces “{name}”"); + // Afterwards the focus is the user's: it is not taken back. + ctx.memory_mut(|m| m.stop_text_input()); + frame(&mut app, &ctx); + assert!(ctx.memory(|m| m.focused()).is_none()); + // Closed and opened again: focused again. + app.dialog = None; + frame(&mut app, &ctx); + frame(&mut app, &ctx); + open_new_solid(&mut app).unwrap(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count())); + } + #[test] fn composition_settings_register_every_control_and_new_comp_remembers() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index afc0fc8a..a1a67bc6 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -173,7 +173,8 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Preset")); let focal = focal_for_zoom(w, d.zoom); @@ -258,7 +259,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Light Type")); egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 2e601759..d8de61d8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -121,7 +121,7 @@ pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json:: /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. -pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { +pub(crate) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { let kid = id.with("gesture"); let n: u64 = if started { let n = ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + 1; @@ -194,6 +194,7 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p app.ui.fx_pick = None; } else { app.ui.fx_pick = Some(pick); + app.ui.text_color_pick = None; app.ui.status = if kind == "point" { format!("Click in the Composition panel to set {}", prop.name) } else { @@ -1120,9 +1121,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); - let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); - app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); + let rename_id = egui::Id::new(("ec-rename", g.uid)); + let editing: Option = ctx.data(|d| d.get_temp(rename_id)); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); + if let Some(mut value) = editing.as_ref().cloned() { + let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); + // Focus the field the frame it first exists: focusing an id not yet on screen leaves + // the accessibility tree with a focus it doesn't contain. + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { + crate::widgets::select_all(&ctx, response.id, &value); + response.request_focus(); + } + app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); + let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if escape || commit { + ctx.data_mut(|d| d.remove::(rename_id)); + if !escape && !value.trim().is_empty() && value != g.name { + actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, value)); + } + } else { + let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); + app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); + } let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click()); bp.text(reset.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim }); app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset"); @@ -1145,8 +1172,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hresp = ui.interact( Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), - Sense::click_and_drag(), + if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() }, ); + if hresp.double_clicked() { + ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); + } hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name)); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { @@ -1164,7 +1195,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if hresp.drag_started() { ctx.data_mut(|d| d.insert_temp(drag_id, g.uid)); } - hresp.context_menu(|ui| { + crate::widgets::context_menu(&hresp, |ui| { let lid = layer.id.0; let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| { if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { @@ -1175,6 +1206,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid})); item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]})); item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid})); + if ui.button(crate::i18n::tr("Rename")).clicked() { + ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); + ui.close(); + } ui.separator(); item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid})); item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid})); @@ -1246,7 +1282,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let open = ui.input(|i| i.pointer.secondary_clicked()) && ui.rect_contains_pointer(body) && (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id)); - egui::Popup::context_menu(&bg).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { + egui::Popup::context_menu(&bg).style(crate::widgets::menu_style).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { ui.set_min_width(200.0); actions.extend(crate::menus::menu_contents(app, ui, "Effect")); }); @@ -1386,7 +1422,50 @@ pub fn viewer_hook( app.ui.status.clear(); } } - let picking = app.ui.fx_pick.is_some(); + if let Some(pick) = app.ui.text_color_pick.clone() { + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::new("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } + } + let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { // ⊕ controls for the point parameters of the selected effects. diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 048b0b58..2e99c7d3 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -129,7 +129,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { // ---- Bottom buttons. ui.horizontal_wrapped(|ui| { - let r = ui.menu_button(tr("Add Formatting"), |ui| { + let r = crate::widgets::menu_button(ui, tr("Add Formatting"), |ui| { if ui.button(tr("Add Group")).clicked() { actions.push(("essential.addGroup".into(), json!({"comp": cid.0}))); ui.close(); @@ -143,7 +143,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { // The selected property as a Font control (Source Text) or a uniform Scale slider. let has_sel = !app.session.state.selected_props.is_empty(); let r = ui.add_enabled_ui(has_sel, |ui| { - ui.menu_button(tr("Add Selected As"), |ui| { + crate::widgets::menu_button(ui, tr("Add Selected As"), |ui| { for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { if ui.button(label).clicked() { actions.push(("essential.addProperty".into(), json!({"as": as_type}))); @@ -270,7 +270,7 @@ fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCon _ => vec![], }; let has_sel = !app.session.state.selected_props.is_empty(); - let r = ui.menu_button(RichText::new("⋯").size(10.0), |ui| { + let r = crate::widgets::menu_button(ui, RichText::new("⋯").size(10.0), |ui| { if ui.button(tr("Add Mirror")).clicked() { actions.push(("essential.addMirror".into(), json!({"comp": cid.0, "control": c.id, "group": group}))); ui.close(); diff --git a/crates/ui-egui/src/panels/expr_bar.rs b/crates/ui-egui/src/panels/expr_bar.rs index fc985d15..4f6d1c74 100644 --- a/crates/ui-egui/src/panels/expr_bar.rs +++ b/crates/ui-egui/src/panels/expr_bar.rs @@ -4,10 +4,11 @@ //! Automation ids: `viewer.exprErrors`, `viewer.exprErrors.prev`, `viewer.exprErrors.next`. use crate::i18n::tr; -use effectcraft_engine::commands::expr_tools::{self, ExprError}; +use effectcraft_engine::commands::expr_tools::ExprError; use effectcraft_engine::project::ItemId; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; +use std::sync::Arc; use crate::EffectcraftApp; use crate::theme::Tokens; @@ -17,35 +18,32 @@ pub const HEIGHT: f32 = 22.0; #[derive(Clone, Default)] struct Cache { - key: (u64, u64, i64), - errors: Vec, + errors: Arc<[ExprError]>, /// Shown error (index). current: usize, } -fn cache_id() -> egui::Id { - egui::Id::new("expr-error-bar") +fn cache_id(cid: ItemId) -> egui::Id { + egui::Id::new(("expr-error-bar", cid.0)) } -/// The comp's failing expressions (recomputed when the project or the time changes; kept while -/// playing). -fn errors(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache { - let t = app.session.time_of(cid); - let key = (app.session.revision, cid.0, t.0); - let mut c: Cache = ctx.data(|d| d.get_temp(cache_id())).unwrap_or_default(); - let stale = c.key != key && !(app.playback.playing && c.key.1 == cid.0); - if stale { - c.errors = expr_tools::errors(&app.session, cid, t); - c.key = key; - c.current = c.current.min(c.errors.len().saturating_sub(1)); - ctx.data_mut(|d| d.insert_temp(cache_id(), c.clone())); +/// Both layout and painting read the same completed diagnostics; neither evaluates expressions. +fn errors(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache { + let mut c: Cache = ctx.data(|d| d.get_temp(cache_id(cid))).unwrap_or_default(); + let interacting = app.playback.playing || ctx.input(|i| i.pointer.any_down()) || app.ui.viewer.interacting || app.ui.viewer.property_interacting; + c.errors = app.expression_diagnostics.errors(&app.session, ctx, cid, interacting); + if let Some(error) = app.expression_diagnostics.take_failure() { + log::warn!("{error}"); + app.ui.status = error; } + c.current = c.current.min(c.errors.len().saturating_sub(1)); + ctx.data_mut(|d| d.insert_temp(cache_id(cid), c.clone())); c } /// Height the bar needs for `cid` (0 when every expression evaluates). -pub fn height(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 { - if app.session.expr.is_none() || errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT } +pub fn height(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 { + if errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT } } pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) { @@ -71,12 +69,12 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId app.auto.add(auto, r, label); if resp.clicked() { c.current = (i + d) % n; - ctx.data_mut(|dd| dd.insert_temp(cache_id(), c.clone())); + ctx.data_mut(|dd| dd.insert_temp(cache_id(cid), c.clone())); } } p.text(count.center(), Align2::CENTER_CENTER, format!("{}/{n}", i + 1), Tokens::ui(11.0), t.text); let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default(); - let e = c.errors[i].clone(); + let Some(e) = c.errors.get(i).cloned() else { return }; let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y)); let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline")); p.with_clip_rect(msg_rect).text( @@ -87,6 +85,8 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId if resp.hovered() { Color32::WHITE } else { t.text }, ); app.auto.add("viewer.exprErrors", msg_rect, &e.describe(&comp_name)); + // Keep the former banner automation id as an alias for the unified error strip. + app.auto.add("viewer.expressionErrorBanner", msg_rect, &e.describe(&comp_name)); if resp.clicked() { let _ = app.session.execute("layer.select", json!({"comp": cid.0, "layers": [e.layer]})); let _ = app.session.execute("prop.select", json!({"comp": cid.0, "layer": e.layer, "prop": e.prop, "selectKeys": false})); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index ffb61738..f800b1a4 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -1,7 +1,6 @@ //! The expression editor of the Timeline, following Settings ▸ Scripting & Expressions: //! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching -//! and Word Wrap; and the Composition panel's expression error banner (Show Expression Error -//! Banner). +//! and Word Wrap. The Composition viewer uses the shared diagnostics in `expr_bar`. use effectcraft_engine::prefs::Scripting; use egui::text::{LayoutJob, TextFormat}; @@ -381,48 +380,6 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: out.response.response } -/// Settings ▸ Scripting & Expressions ▸ Show Expression Error Banner: the first failing -/// expression of the comp at the current time, in a banner along the bottom of the viewer -/// (click it to reveal the property). -pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) { - if !app.session.prefs.scripting.error_banner { - return; - } - let Some(cid) = app.session.active_comp_id() else { return }; - let key = (app.session.revision, cid.0, app.session.time().0); - let id = egui::Id::new("expr-error-banner"); - let cached: Option<((u64, u64, i64), Vec)> = ui.ctx().data(|d| d.get_temp(id)); - let errors = match cached { - Some((k, e)) if k == key => e, - _ => { - let e = app.session.expression_errors(cid); - ui.ctx().data_mut(|d| d.insert_temp(id, (key, e.clone()))); - e - } - }; - let Some(first) = errors.first() else { return }; - let layer = first["layer"].as_u64().unwrap_or(0); - let lname = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).map(|l| l.name.clone()).unwrap_or_default(); - let msg = format!( - "⚠ Expression error{}: {} ▸ {}: {}", - if errors.len() > 1 { format!(" (1 of {})", errors.len()) } else { String::new() }, - lname, - first["name"].as_str().unwrap_or(""), - first["error"].as_str().unwrap_or("").lines().next().unwrap_or("") - ); - let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); - let p = ui.painter(); - p.rect_filled(r, 0.0, Color32::from_rgb(0xf0, 0xc0, 0x30)); - p.text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, msg, Tokens::ui(11.5), Color32::BLACK); - app.auto.add("viewer.expressionErrorBanner", r, "Expression error banner"); - if ui.interact(r, id.with("click"), egui::Sense::click()).clicked() { - let p = serde_json::json!({"props": [{"layer": layer, "prop": first["prop"]}]}); - if let Err(e) = crate::menus::invoke(app, ui.ctx(), "timeline.revealProps", p) { - app.ui.status = e; - } - } -} - #[cfg(test)] mod contrast_tests { use super::*; diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index a8651464..a9e7cefb 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -19,7 +19,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.pasteboard); let Some(view) = app.session.state.footage_panel.clone() else { - p.text(rect.center(), Align2::CENTER_CENTER, tr("Double-click footage in the Project panel to open it here"), Tokens::ui(12.0), t.text_faint); + // Wrapped to the panel, so a narrow panel breaks the hint into lines instead of clipping it. + let hint = tr("Double-click footage in the Project panel to open it here"); + let mut job = egui::text::LayoutJob::simple(hint.to_owned(), Tokens::ui(12.0), t.text_faint, (rect.width() - 20.0).max(40.0)); + job.halign = egui::Align::Center; + let galley = p.layout_job(job); + p.galley(pos2(rect.center().x, rect.center().y - galley.size().y * 0.5), galley, t.text_faint); app.auto.add("footage.empty", rect, "Footage panel (empty)"); return; }; diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index b04e017b..2193b605 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -765,17 +765,32 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let h = 120.0 + rows; let mut regs: Vec<(String, egui::Rect, String)> = vec![]; let mut browse: Option = None; - // Wide enough for a label column and a 240 px popup without a horizontal scroll bar. - super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| { + let mut focused = false; + // Project Settings needs room for its labels and the selected GPU adapter name. At the + // generic form width its grid overflows, pushing the buttons off the visible area (#978). + let width = if f.title == "Project Settings" { 800.0 } else { 480.0 }; + super::dialogs::modal(ctx, &f.title.clone(), vec2(width, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { for (i, fl) in f.fields.iter_mut().enumerate() { ui.label(tr(&fl.label)); let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))); + // The first field takes the keyboard when the form opens. (A number edits + // as text once focused; the selection is clamped to what it shows.) + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, &" ".repeat(64)); + } + r.rect + } + FieldKind::Text(s) => { + let r = ui.add(egui::TextEdit::singleline(s).desired_width(220.0)); + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, s); + } + r.rect } - FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect; @@ -891,7 +906,8 @@ pub(crate) fn close_info(app: &mut EffectcraftApp) { pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (title, body) = app.dialog_state.info.clone(); let mut close = false; - super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| { + // The text wraps within this small dialog; avoid a horizontal scrollbar changing its height. + super::dialogs::modal_vertical(ctx, &title, vec2(440.0, 160.0), t, |ui| { ui.label(tr(&body)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -947,6 +963,37 @@ mod tests { assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"})); } + /// A numeric form (Layer ▸ Transform ▸ Position…) opens with its first number being edited + /// and selected: typing replaces it, and one Enter confirms. + #[test] + fn a_numeric_form_opens_with_its_first_number_selected() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.dialog_state.form = Form { + title: "Position".into(), + command: "layer.setTransform".into(), + base: json!({"prop": "position"}), + fields: vec![Field::num("value[0]", "X", 960.0), Field::num("value[1]", "Y", 540.0)], + }; + app.dialog = Some(Dialog::Form); + let frame = |app: &mut EffectcraftApp, events: Vec| { + let raw = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + crate::panels::dialogs::show(app, ui.ctx()); + }) + .textures_delta + .clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("42".into())]); + assert_eq!(app.dialog_state.form.params()["value"], json!([42.0, 540.0])); + frame(&mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert!(app.dialog.is_none(), "one Enter confirms"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] diff --git a/crates/ui-egui/src/panels/gradient_handles.rs b/crates/ui-egui/src/panels/gradient_handles.rs new file mode 100644 index 00000000..bfe87a47 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient_handles.rs @@ -0,0 +1,104 @@ +//! A shape layer's Gradient Fill / Gradient Stroke start and end points in the Composition viewer +//! (#612). With the gradient, or a property in it, selected, its Start Point and End Point show as +//! draggable ⊕ controls with a guide between them, as in After Effects. A drag sets the point with +//! `prop.set`: one undo step per drag, and on an animated point a keyframe at the current time. +//! +//! The points are in the space of the shape group holding the gradient, so they follow that +//! group's transform (and the groups around it) as well as the layer's. +//! +//! Automation ids: `viewer.gradientPoint.` (the Start Point / End Point property's uid). + +use effectcraft_engine::geom::Mat3; +use effectcraft_engine::project::{Layer, LayerId, Node, PropGroup, Property}; +use effectcraft_engine::render::EvalCtx; +use egui::{Color32, Rect, Sense, Stroke, vec2}; +use serde_json::json; + +use super::fx_widgets as fw; +use crate::EffectcraftApp; + +/// Gradient Fill / Gradient Stroke groups in shape contents `g`, through nested groups. +fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGroup>) { + if depth >= 16 { + return; + } + for child in g.groups().filter(|c| c.enabled) { + match child.match_id.as_str() { + "gfill" | "gstroke" => out.push(child), + "group" | "contents" => gradient_paints(child, depth + 1, out), + _ => {} + } + } +} + +/// `g` or something inside it is selected on `layer`. +fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool { + fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool { + g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1)))) + } + selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0)) +} + +/// Draw and drag the selected gradients' start and end points. `l2c` is the viewer's layer→comp +/// matrix (it knows the 3D view). Called after the viewer's own gestures, so a handle takes the +/// press before the layer would move. +pub fn viewer_hook( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, +) { + if !app.ui.viewer.show_layer_controls { + return; + } + let t = app.tokens; + let ctx = ui.ctx().clone(); + let selected_props = app.session.state.selected_props.clone(); + let selected_layers = app.session.state.selected_layers.clone(); + let mut actions: Vec<(String, serde_json::Value)> = vec![]; + for layer in ectx.comp.layers.iter().filter(|l| selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) { + let Some(contents) = layer.props.sub("contents") else { continue }; + let mut paints = vec![]; + gradient_paints(contents, 0, &mut paints); + if paints.is_empty() { + continue; + } + let m = l2c(ectx, layer); + for g in paints.into_iter().filter(|g| selected_in(g, layer.id, &selected_props)) { + // Gradient (group) space → comp: the enclosing groups' transforms, then the layer's. + let pm = m * effectcraft_engine::render::shapes::item_matrix(ectx, layer, g.uid).unwrap_or(Mat3::IDENTITY); + let pts: Vec<&Property> = ["start", "end"].iter().filter_map(|k| g.get(k)).collect(); + let screen: Vec = pts.iter().map(|pr| map.to_screen(fw::layer_to_comp(&pm, ectx.value(layer, pr).as_vec2()))).collect(); + if let [a, b] = screen[..] { + painter.line_segment([a, b], Stroke::new(1.0, Color32::from_white_alpha(110))); + } + for (pr, s) in pts.iter().zip(screen) { + let hr = Rect::from_center_size(s, vec2(16.0, 16.0)); + let id = egui::Id::new(("viewer-gradient-pt", pr.uid)); + let resp = ui.interact(hr, id, Sense::drag()); + let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE }; + fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160)); + fw::draw_crosshair(painter, s, 5.0, col); + app.auto.add(&format!("viewer.gradientPoint.{}", pr.uid), hr, &pr.name); + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name)); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(&pm, map.to_comp(pos)) + { + let key = super::effect_controls::gesture_key(ui, id, resp.drag_started()); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key}))); + } + } + } + } + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index a5952d60..e5d8e4e0 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -44,6 +44,27 @@ fn tex_id() -> egui::Id { egui::Id::new("layer-panel-tex") } +fn fit_id() -> egui::Id { + egui::Id::new("layer-panel-fit") +} + +/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom +/// Out from Fit. +pub fn last_fit(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(fit_id())).unwrap_or(0.5) +} + +/// Zoom to `nz` (points per layer pixel) keeping layer point `q` under screen point `at`. +fn zoom_at(app: &mut EffectcraftApp, area: Rect, size: [f64; 2], at: Pos2, q: [f64; 2], nz: f32) { + let nz = nz.clamp(0.01, 32.0); + let origin = at - vec2(q[0] as f32 * nz, q[1] as f32 * nz); + let pan = origin + vec2(size[0] as f32 * nz / 2.0, size[1] as f32 * nz / 2.0) - area.center(); + if pan.x.is_finite() && pan.y.is_finite() { + app.ui.layer_zoom = Some(nz); + app.ui.layer_pan = [pan.x, pan.y]; + } +} + /// The layer shown: the opened one, else the first selected layer. pub fn current_layer(app: &EffectcraftApp) -> Option { let comp = app.session.active_comp()?; @@ -83,15 +104,46 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(top, 0.0, t.panel_bg); p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, tr_args("Layer: {}", &[&layer.name]), Tokens::semibold(12.0), t.text); app.auto.add("layerPanel.title", top, &layer.name); - // Bottom bar: View menu. + // Canvas geometry: Fit, or the panel's own magnification and pan. let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max); + let canvas_bottom = if roto.is_some() { bar.min.y - 18.0 } else { bar.min.y }; + let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom)); + // The layer's effect bounds, in effect space (where paint and Roto Brush strokes are). + let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0; + let fit = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01) as f32; + ctx.data_mut(|d| d.insert_temp(fit_id(), fit)); + let zoom = app.ui.layer_zoom.filter(|z| z.is_finite() && *z > 0.0).unwrap_or(fit) as f64; + let pan = match app.ui.layer_pan { + [x, y] if x.is_finite() && y.is_finite() && app.ui.layer_zoom.is_some() => vec2(x, y), + _ => vec2(0.0, 0.0), + }; + let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32); + + // Bottom bar: magnification and View menus. p.rect_filled(bar, 0.0, t.panel_bg); - p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim); + let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0)); + if let Some((z, up)) = super::viewer_tools::magnification_menu( + app, + ui, + mr, + "layer-mag", + "layerPanel.magnification", + "layerMagnification", + zoom as f32, + app.ui.layer_zoom, + false, + ) { + // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %. + app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z }; + app.ui.layer_pan = [0.0, 0.0]; + } + let vx = mr.max.x + 10.0; + p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim); let mut names = vec!["Masks".to_string()]; if let Some(fx) = layer.effects() { names.extend(fx.groups().map(|g| g.name.clone())); } - let vr = Rect::from_min_size(pos2(bar.min.x + 46.0, bar.center().y - 10.0), vec2(150.0, 20.0)); + let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0)); let pop = egui::Id::new("layer-view-pop"); if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() { widgets::open_popup(ui, pop); @@ -103,21 +155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc = super::timecode(&app.session, &comp, time); p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode); // Roto Brush: view / Freeze buttons and the segmentation span bar. - let mut canvas_bottom = bar.min.y; if roto.is_some() { super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer); - let sb = Rect::from_min_max(pos2(rect.min.x, bar.min.y - 18.0), pos2(rect.max.x, bar.min.y)); + let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y)); super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer); - canvas_bottom = sb.min.y; } // Canvas. - let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom)); p.rect_filled(area, 0.0, t.pasteboard); - // The layer's effect bounds, in effect space (where paint and Roto Brush strokes are). - let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0; - let zoom = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01); - let origin = area.center() - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32); let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32); let to_layer = |s: Pos2| [((s.x - origin.x) as f64) / zoom, ((s.y - origin.y) as f64) / zoom]; let frame = Rect::from_min_max(to_screen([0.0, 0.0]), to_screen([lw, lh])); @@ -166,6 +211,53 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any + // tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out). + let hovered = ui.rect_contains_pointer(area); + if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) { + let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); + if scroll.is_finite() && scroll.abs() > 0.1 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp()); + } + } + // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. + if hovered && ui.input(|i| i.pointer.middle_down()) { + // Movement from before the press (in its frame) is not part of the drag. + let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() }); + if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + d.x, pan.y + d.y]; + } + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + return; + } + let space = super::space_hand(&ctx); + if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) { + let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag()); + let alt = ui.input(|i| i.modifiers.alt); + if resp.hovered() { + ctx.set_cursor_icon(match app.ui.tool { + Tool::Zoom if !space && alt => egui::CursorIcon::ZoomOut, + Tool::Zoom if !space => egui::CursorIcon::ZoomIn, + _ if resp.dragged() => egui::CursorIcon::Grabbing, + _ => egui::CursorIcon::Grab, + }); + } + if resp.dragged_by(egui::PointerButton::Primary) && (space || app.ui.tool == Tool::Hand) { + let d = resp.drag_delta(); + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + d.x, pan.y + d.y]; + } + if resp.clicked() + && !space + && app.ui.tool == Tool::Zoom + && let Some(pos) = resp.interact_pointer_pos() + { + zoom_at(app, area, [lw, lh], pos, to_layer(pos), zoom as f32 * if alt { 0.5 } else { 2.0 }); + } + app.auto.add("layerPanel.nav", area, "Layer panel navigation"); + return; + } // Interaction: Roto Brush / Refine Edge. if app.ui.tool.roto_kind(false).is_some() { super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer); diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index c3631604..edf46f4e 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -124,6 +124,8 @@ fn text_field(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut Str pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.marker.clone(); let (mut ok, mut cancel, mut delete) = (false, false, false); + // Cmd+Backspace (Ctrl elsewhere) is Delete, in a text field too. + let delete_key = super::dialogs::ui_command_key(ctx, egui::Key::Backspace); let title = d.title.clone(); super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -137,6 +139,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Comment:")); let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.comment); app.auto.add("dialog.marker.comment", r.rect, "Comment"); ui.end_row(); ui.label(tr("Chapter:")); @@ -217,7 +220,9 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); }); app.dialog_state.marker = d.clone(); - if ok { + delete |= delete_key && !ok && !cancel; + // Enter is OK, unless a text field has it. + if ok || (!cancel && !delete && super::dialogs::ui_enter(ctx)) { if let Err(e) = app.session.execute("markers.set", draft_params(&d)) { app.ui.status = e.to_string(); } @@ -337,7 +342,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: ctx.data_mut(|d| d.remove::<(Option, usize, bool)>(drag_state_id())); app.session.history.merge_key = None; } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let run = |ui: &mut egui::Ui, label: &str, cmd: &str, params: Value, app: &mut EffectcraftApp| { if ui.button(label).clicked() { if cmd == "dialog" { @@ -496,5 +501,32 @@ mod tests { assert_eq!(m["label"], "Blue"); app.session.undo(); assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], ""); + // Enter is OK and Cmd+Backspace is Delete. + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + // The dialog opens with Comment focused (#922), a multi-line field where Enter types a + // new line; with the focus elsewhere, Enter is OK. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.auto.begin_frame(); + crate::panels::dialogs::show(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + ctx.memory_mut(|m| m.stop_text_input()); + app.dialog_state.marker.chapter = "Entered".into(); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert!(app.dialog.is_none()); + assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], "Entered"); + open_dialog(&mut app, MarkerRef { layer: None, index: 0 }).unwrap(); + // Also from a focused text field: Tab to one, then press the key. + for _ in 0..40 { + if ctx.egui_wants_keyboard_input() { + break; + } + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::NONE); + } + assert!(ctx.egui_wants_keyboard_input(), "a text field has focus"); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Backspace, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none()); + assert_eq!(app.session.execute("markers.list", json!({})).unwrap().as_array().map(Vec::len), Some(0), "deleted"); } } diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index cd6dca5d..4a0ab6ae 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -208,7 +208,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.drag_started() && !e.is_dir && e.kind.is_some() { egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()])); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if !e.is_dir && ui.button(tr("Import")).clicked() { actions.push(("mediaBrowser.import", json!({"paths": [e.path]}))); ui.close(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bc07c258..cc7910b3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -18,6 +18,7 @@ pub mod forms; pub mod fx_editors; pub mod fx_widgets; pub mod gradient_editor; +pub mod gradient_handles; pub mod graph; pub mod graph_tools; pub mod home; @@ -174,6 +175,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let id = egui::Id::new("panel-menu"); let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return }; + // The menu opens in the window that shows the panel (a panel can have its own window). + if app.panel_viewport(ui.ctx(), panel) != ui.ctx().viewport_id() { + return; + } let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -188,6 +193,14 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r }); close = true; } + // Multiple monitors: a native window of its own, which can go on another screen. + if !ui.ctx().embed_viewports() { + let windowed = app.in_panel_window(panel); + if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "window.panelWindow", serde_json::json!({"panel": panel, "window": !windowed})); + close = true; + } + } if !floating && ui.button(if app.ui.maximized == Some(panel) { tr("Restore Panel Size") } else { tr("Maximize Panel") }).clicked() { app.toggle_maximize(panel); close = true; diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index f4bf4272..8a40c56e 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -115,6 +115,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("New composition name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); app.auto.add("dialog.precompose.name", r.rect, "New composition name"); }); ui.add_space(10.0); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 64bc43b6..e18f76bc 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -610,15 +610,36 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list); + // Footage that can't be decoded (no decoder for its codec / profile): a warning marker + // at the end of the name cell, the reason on hover. + let problem = match &it.kind { + ItemKind::Footage(f) => app.session.footage.problem(f), + _ => None, + }; + let name_clip = + Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); + if let Some(why) = &problem { + let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0)); + lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE)); + lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK); + ui.interact(m.expand(2.0).intersect(list), egui::Id::new(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str()); + app.auto.add(&format!("project.item.{}.problem", id.0), m, why); + } let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0)); // Inline edits (rename / comment). let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool { let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false }; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell)); - let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0))); + // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no + // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel. + let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter)); app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field); - if resp.lost_focus() { + let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab)); + if tab { + resp.surrender_focus(); + } + if resp.lost_focus() || tab { ctx.data_mut(|d| d.remove::(edit_id())); if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { let (cmd, params) = if ef == "name" { @@ -760,7 +781,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.project_selection = vec![*id]; } let footage = matches!(it.kind, ItemKind::Footage(_)); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr("Delete")).clicked() { actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); ui.close(); @@ -774,7 +795,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.close(); } if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() { - actions.push(("project.move".into(), json!({"items": [id.0], "folder": null}))); + actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null}))); ui.close(); } if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() { @@ -927,7 +948,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } - empty.context_menu(|ui| { + crate::widgets::context_menu(&empty, |ui| { for (key, label, command) in [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")] { @@ -993,7 +1014,7 @@ fn label_index(l: Label) -> usize { /// The column header's context menu: show or hide the optional columns. fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { - resp.context_menu(|ui| { + crate::widgets::context_menu(resp, |ui| { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index b66d0d47..e07efe69 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -559,7 +559,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.focused = crate::dock::PanelKind::RenderQueue; } let item_id = it.id; - row_resp.context_menu(|ui| { + crate::widgets::context_menu(&row_resp, |ui| { if ui.button(tr("Duplicate")).clicked() { actions.push(("renderQueue.duplicate", json!({"item": item_id}))); ui.close(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ae881a37..f7a45feb 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -333,11 +333,39 @@ fn theme_card( theme_preview(ui, kind, ui.available_width()); ui.add_space(4.0); for (label, value) in options { - let r = ui.radio(cur == *value, tr(label)); - reg(app, &format!("settings.{key}.{value}"), &r, label); - if r.clicked() && cur != *value { - acts.push(Act::Set(key.into(), json!(value))); - } + let row = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio(cur == *value, tr(label)); + reg(app, &format!("settings.{key}.{value}"), &r, label); + if r.clicked() && cur != *value { + acts.push(Act::Set(key.into(), json!(value))); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut favorites = app.session.prefs.appearance.favorite_themes.clone(); + let favorite = favorites.iter().any(|id| id == value); + let tip = tr(if favorite { "Remove from favorites" } else { "Add to favorites" }); + let r = ui.add_visible(favorite || hovered, egui::Button::new("").min_size(vec2(24.0, 24.0)).frame(false)).on_hover_text(tip); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{}: {}", tip, tr(label)))); + reg(app, &format!("settings.appearance.favoriteThemes.{value}"), &r, tip); + if favorite || hovered { + crate::icons::paint( + ui.painter(), + r.rect.shrink(4.0), + if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, + if favorite { t.accent } else { t.text_dim }, + ); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != value); + } else { + favorites.push((*value).into()); + } + acts.push(Act::Set("appearance.favoriteThemes".into(), json!(favorites))); + } + }); + }); } }); } @@ -788,6 +816,9 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); }); + if choice.is_none() && super::dialogs::ui_enter(ctx) { + choice = Some("autosave"); + } let Some(c) = choice else { return }; app.dialog = None; app.recovery = None; diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 021dffe7..c8948ab8 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -420,6 +420,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // ---- search + command list + editor ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tr("Search commands or shortcuts")).desired_width(360.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &st.query); app.auto.add("shortcuts.search", r.rect, "Search"); }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 69db87b0..ae2294f1 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -8,6 +8,7 @@ use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; +use crate::state::TextColorPick; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -201,7 +202,21 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } - p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0)); + let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key); + let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t); + app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper")); + if eye.clicked() { + if active { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else if let Some(tt) = &target { + app.ui.fx_pick = None; + app.ui.text_color_pick = Some(TextColorPick { layer: tt.layer, field: key.into(), range: tt.range }); + app.ui.status = crate::i18n::tr_args("Click in the Composition panel to sample text {}", &[&label]); + } + } + p.text(pos2(er.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); } y += 32.0; // Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ae4d26af..72f10429 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -578,6 +578,18 @@ pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// What double-clicking a layer's name or bar does, as in After Effects: a precomp opens, footage +/// and solids open in the Layer panel (paint happens there), text is edited in the viewer with the +/// whole text selected. `None`: the name renames, the bar does nothing. +fn double_click_action(layer: &Layer) -> Option<(String, serde_json::Value)> { + match &layer.source { + LayerSource::Comp { item } => Some(("comp.open".into(), json!({"comp": item.0}))), + LayerSource::Footage { .. } | LayerSource::Solid { .. } => Some(("layer.openLayer".into(), json!({"layer": layer.id.0}))), + LayerSource::Text => Some(("text.edit".into(), json!({"layer": layer.id.0}))), + _ => None, + } +} + /// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only. fn start_rename(ctx: &egui::Context, id: u64, name: &str) { ctx.data_mut(|d| { @@ -1169,9 +1181,16 @@ fn layer_switch( id: egui::Id, layer: u64, name: &'static str, + others: bool, actions: &mut Vec<(String, serde_json::Value)>, ) { let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); + // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), + // and come back on the next Alt-click. + if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) { + actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true}))); + return; + } if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) { actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { @@ -1419,7 +1438,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag()); app.auto.add("timeline.workArea.bar", bar, "Work area"); // Right-click: the work area's commands (After Effects). - bresp.context_menu(|ui| { + crate::widgets::context_menu(&bresp, |ui| { let mut chosen = None; for id in ["comp.trimToWorkArea", "edit.liftWorkArea", "edit.extractWorkArea"] { crate::menus::entry_for(app, ui, id, &mut chosen); @@ -1662,7 +1681,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x)); ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-colhdr-under"), under.unwrap_or(""))); } - hresp.context_menu(|ui| { + crate::widgets::context_menu(&hresp, |ui| { let under: &str = ctx.data(|d| d.get_temp(egui::Id::new("tl-colhdr-under"))).unwrap_or(""); if !under.is_empty() && under != "name" && ui.button(crate::i18n::tr("Hide This")).clicked() { actions.push(("timeline.column".into(), json!({"column": under, "visible": false}))); @@ -1787,7 +1806,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } // Label swatch. @@ -1881,20 +1900,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if row_resp.double_clicked() { - match &layer.source { - LayerSource::Comp { item } => actions.push(("comp.open".into(), json!({"comp": item.0}))), - // Footage and solids open in the Layer panel (paint happens there). - LayerSource::Footage { .. } | LayerSource::Solid { .. } => { - actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0}))); - } - _ => start_rename(&ctx, layer.id.0, &layer.name), + match double_click_action(layer) { + Some(a) => actions.push(a), + None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, layer, &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), - (Icon::Collapse, sw.collapse, "collapse", matches!(layer.source, LayerSource::Comp { .. } | LayerSource::Shape | LayerSource::Text)), + (Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)), (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()), (Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())), ( @@ -1913,7 +1928,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } // Modes. @@ -1942,7 +1957,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), }; - let tresp = widgets::dropdown(ui, mr, &label, &t, egui::Id::new(("trkmat", layer.id.0))); + // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu. + let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label()))); + let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::new(("trkmat", layer.id.0))); app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte"); let pop = egui::Id::new(("trk-pop", layer.id.0)); if tresp.clicked() { @@ -1962,17 +1979,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { opts.push(k.label().into()); } } - if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) { - if i == 0 { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); - } else if i >= 2 && i < 2 + candidates.len() { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - actions - .push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": candidates[i - 2].id.0, "kind": kind_name(kind)}))); - } else if let Some(k) = MatteKind::ALL.into_iter().find(|k| k.label() == opts[i]) - && let Some(m) = layer.track_matte - { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.layer.0, "kind": kind_name(k)}))); + let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); + if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { + let ids: Vec = candidates.iter().map(|l| l.id).collect(); + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + actions.push(a); } } if layer.track_matte.is_some() { @@ -2095,6 +2106,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } + if body.double_clicked() + && let Some(a) = double_click_action(layer) + { + actions.push(a); + } let shift = ui.input(|i| i.modifiers.shift); if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() { // Shift-click: the layers from the last one clicked to this one, as in @@ -2108,7 +2124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, layer, &mut actions); + layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if resp.dragged() { @@ -2119,14 +2135,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if frames != 0 { acc -= (frames as f64 * fd * pps) as f32; let d = frames as f64 * fd; + // Moving or trimming a selected layer acts on the whole + // selection; each layer trims from its own in/out point. + let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; let params = match kind { - "move" => { - json!({"layers": if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }, "delta": d, "merge": drag_key}) - } - "in" => json!({"layers": [layer.id.0], "in": (layer.in_point.seconds() + d).max(0.0), "merge": drag_key}), - _ => { - json!({"layers": [layer.id.0], "out": (layer.out_point.seconds() + d).min(comp.duration.seconds()), "merge": drag_key}) - } + "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), + "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), + _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}), }; actions.push(("layer.timing".into(), params)); } @@ -2323,7 +2338,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu"); let mut picked: Option<&'static str> = None; - egui::Popup::menu(&lang).show(|ui| { + egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { widgets::menu_scroll(ui, |ui| { @@ -2486,7 +2501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if name_resp.clicked() { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)}))); } - name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); + crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); // Value editors. let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); @@ -2578,7 +2593,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::SetTime(ct)); } } - kresp.context_menu(|ui| { + crate::widgets::context_menu(&kresp, |ui| { for (lbl, cmd, params) in [ ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})), ("-", "", json!({})), @@ -2669,10 +2684,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("timeline.empty", empty_rect, "Right-click to create a layer"); } for (response, area) in [(&outline_empty, "outline"), (&empty, "graph")] { - response.context_menu(|ui| { + crate::widgets::context_menu(response, |ui| { let menu = ui.menu_button(crate::i18n::tr("New"), |ui| { for (key, label, command, params) in [ - ("text", "Text", "layer.newText", json!({"text": "Text"})), + ("text", "Text", "layer.newText", json!({})), ("solid", "Solid…", "layer.newSolid", json!({})), ("light", "Light…", "layer.newLight", json!({})), ("camera", "Camera…", "layer.newCamera", json!({})), @@ -2695,6 +2710,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("comp.settings".into(), json!({}))); ui.close(); } + let response = ui.button(crate::i18n::tr("Open in Essential Graphics")); + app.auto.add(&format!("timeline.context.{area}.essentialGraphics"), response.rect, "Open in Essential Graphics"); + if response.clicked() { + actions.push(("comp.openInEssentialGraphics".into(), json!({}))); + ui.close(); + } }); } if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -3412,6 +3433,21 @@ fn text_anim_popups( } } +/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track +/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte +/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). +fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { + if i == 0 { + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + } + if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { + let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + } + let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; + Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) +} + fn kind_name(k: MatteKind) -> &'static str { match k { MatteKind::Alpha => "alpha", @@ -3458,12 +3494,42 @@ fn prop_menu( item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid})); } -fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { - resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); +/// Whether `layer` shows vector footage (SVG, PDF, AI, EPS). +pub(crate) fn vector_footage_layer(project: &effectcraft_engine::project::Project, layer: &Layer) -> bool { + layer + .source + .item() + .and_then(|item| project.item(item)) + .is_some_and(|item| matches!(&item.kind, effectcraft_engine::project::ItemKind::Footage(footage) if effectcraft_engine::vector::is_vector(footage))) +} + +/// Whether the Collapse Transformations / Continuously Rasterize switch applies: precomps, shape and text +/// layers, and vector footage (SVG, PDF, AI, EPS), which the renderer re-rasterizes at the layer's scale. +fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Project) -> bool { + match &layer.source { + LayerSource::Comp { .. } | LayerSource::Shape | LayerSource::Text => true, + LayerSource::Footage { item } => { + let kind = project.item(*item).map(|i| &i.kind); + matches!(kind, Some(effectcraft_engine::project::ItemKind::Footage(f)) if effectcraft_engine::render::is_vector_footage(f)) + } + _ => false, + } +} + +/// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part +/// of the selection keeps the selection and the menu acts on all of it; right-clicking an +/// unselected layer selects only that layer. +fn layer_context_menu(resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { + let in_selection = selected.contains(&layer.id); + if resp.secondary_clicked() && !in_selection && !layer.switches.locked { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); + } + let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, vector, actions)); } /// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(String, serde_json::Value)>) { +pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { @@ -3473,7 +3539,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S } }; ui.menu_button(crate::i18n::tr("New"), |ui| { - item(ui, "Text", "layer.newText", json!({"text": "Text"})); + item(ui, "Text", "layer.newText", json!({})); item(ui, "Solid…", "app.solidSettings", json!({})); item(ui, "Light…", "layer.newLight", json!({})); item(ui, "Camera…", "layer.newCamera", json!({})); @@ -3482,6 +3548,9 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); + if vector { + item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); + } ui.separator(); ui.menu_button(crate::i18n::tr("Masks"), |ui| { item(ui, "New Mask", "layer.addMask", json!({"layer": id})); @@ -3746,6 +3815,58 @@ mod tests { assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); } + /// #864: picking only a mode in a layer's Track Matte menu sets a matte (the layer above); + /// it used to close the menu and change nothing. + #[test] + fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + for name in ["Chain Bottom", "Matte", "Chain Top"] { + s.execute("layer.newSolid", json!({"name": name, "color": "#ffffff", "width": 64, "height": 64})).unwrap(); + } + let find = |s: &effectcraft_engine::Session, n: &str| s.active_comp().unwrap().layers.iter().find(|l| l.name == n).unwrap().clone(); + let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); + let candidates = [top.id, matte.id]; + let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { + let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + s.execute(&id, params) + }; + pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); + let m = find(&s, "Chain Bottom").track_matte.expect("a matte"); + assert_eq!((m.layer, m.kind), (matte.id, MatteKind::AlphaInverted)); + assert!(!find(&s, "Matte").switches.video, "the matte layer is hidden"); + // A mode change keeps the matte layer; a matte change keeps the mode. + pick(&mut s, "Chain Bottom", 6, "Luma Matte").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Luma))); + pick(&mut s, "Chain Bottom", 2, "1. Chain Top").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((top.id, MatteKind::Luma))); + assert!(s.undo()); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| m.layer), Some(matte.id), "one undo step per pick"); + // The top layer has nothing above it: an error for the status bar, not a silent no-op. + assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); + pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); + assert!(find(&s, "Chain Bottom").track_matte.is_none()); + assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + } + + /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. + #[test] + fn collapse_switch_shows_for_vector_footage_only() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Footage, ItemKind, Project}; + let mut p = Project::default(); + let mut layer_for = |name: &str, codec: &str| { + let footage = Footage { path: name.into(), codec: codec.into(), ..Default::default() }; + let item = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage)); + let comp = Comp::new(64, 36, effectcraft_engine::time::FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + effectcraft_engine::project::build::layer(&mut p, &comp, name, LayerSource::Footage { item }, (0, 0), None) + }; + let svg = layer_for("logo.svg", "SVG"); + let png = layer_for("photo.png", "PNG"); + assert!(collapse_applies(&svg, &p)); + assert!(!collapse_applies(&png, &p)); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); @@ -3907,6 +4028,28 @@ mod tests { assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}"); } + /// U and UU show Time Remap on a layer with time remapping enabled. + #[test] + fn u_and_uu_reveal_time_remap() { + let mut s = effectcraft_engine::Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap(); + let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap(); + app.session.execute("layer.select", json!({"layers": [nested]})).unwrap(); + for uu in [false, true] { + app.ui.timeline.layer_reveal.clear(); + app.last_reveal = uu.then(|| ("keyframes".into(), f64::MAX)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["props".to_string()]), "UU: {uu}"); + let rows = labels(&app); + let at = rows.iter().position(|r| r == "Inner").unwrap(); + assert_eq!(rows[at + 1].trim(), "Time Remap", "UU: {uu}: {rows:?}"); + } + } + /// Enabling time remapping twirls the layer open on Time Remap (#157); disabling it reveals /// nothing. #[test] diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index e403a9d6..05f719fa 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -5,7 +5,8 @@ //! Revert only asks to discard. Agents running commands with `engine.execute` are never asked. //! //! Automation ids: `dialog.unsaved.save`, `dialog.unsaved.dontSave` (Revert: `dialog.unsaved.revert`), -//! `dialog.unsaved.cancel`. +//! `dialog.unsaved.cancel`. Enter answers Save, Cmd+Backspace (Ctrl elsewhere) Don't Save and, in +//! the Revert prompt, Cmd+R Revert. use crate::i18n::tr; use egui::{Color32, vec2}; @@ -73,6 +74,11 @@ pub fn window_title(app: &EffectcraftApp) -> String { format!("EffectCraft - {doc}{}", if app.session.is_dirty() { " *" } else { "" }) } +/// Whether the prompt is Revert Project (Revert / Cancel) instead of Unsaved Changes. +pub(crate) fn is_revert(app: &EffectcraftApp) -> bool { + app.dialog_state.unsaved.command.as_ref().is_some_and(|(id, _)| id == "file.revert") +} + enum Answer { Save, Discard, @@ -80,11 +86,11 @@ enum Answer { } pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { - let Some((id, _)) = app.dialog_state.unsaved.command.clone() else { + if app.dialog_state.unsaved.command.is_none() { app.dialog = None; return; - }; - let revert = id == "file.revert"; + } + let revert = is_revert(app); let name = project_name(app); let mut answer = None; let title = if revert { "Revert Project" } else { "Unsaved Changes" }; @@ -102,7 +108,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add_space(18.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (label, key) = if revert { (" Revert ", "dialog.unsaved.revert") } else { (" Save ", "dialog.unsaved.save") }; - let r = ui.add(egui::Button::new(egui::RichText::new(label).color(Color32::WHITE)).fill(t.accent)); + // Revert discards work: red, like Delete. + let r = ui.add(egui::Button::new(egui::RichText::new(label).color(Color32::WHITE)).fill(if revert { t.danger } else { t.accent })); app.auto.add(key, r.rect, label.trim()); if r.clicked() { answer = Some(if revert { Answer::Discard } else { Answer::Save }); @@ -126,6 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(Answer::Cancel); } + // Enter is Save, the default button; Cmd+Backspace (Ctrl elsewhere) is Don't Save, as in + // macOS save prompts. Revert discards work, so it takes neither: Cmd+R. + if answer.is_none() && !revert && super::dialogs::ui_enter(ctx) { + answer = Some(Answer::Save); + } + let discard = if revert { egui::Key::R } else { egui::Key::Backspace }; + if answer.is_none() && super::dialogs::ui_command_key(ctx, discard) { + answer = Some(Answer::Discard); + } if let Some(a) = answer { answer_with(app, ctx, a); } @@ -227,6 +243,59 @@ mod tests { assert!(app.dialog.is_none() && app.session.project.comps().count() == 0); } + #[test] + fn cmd_backspace_is_dont_save_but_not_revert() { + let press = |app: &mut EffectcraftApp, ctx: &egui::Context| crate::panels::dialogs::press_key(app, ctx, egui::Key::Backspace, egui::Modifiers::COMMAND); + let (mut app, ctx) = app(); + modified(&mut app); + crate::menus::invoke(&mut app, &ctx, "file.newProject", json!({})).unwrap(); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + press(&mut app, &ctx); + assert!(app.dialog.is_none() && !app.session.is_dirty()); + assert_eq!(app.session.project.comps().count(), 0, "closed without saving"); + // The Revert prompt has no Don't Save. + let dir = std::env::temp_dir().join(format!("ec-unsaved-key-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + modified(&mut app); + app.session.execute("file.saveAs", json!({"path": dir.join("Kept.ecproj").to_string_lossy()})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.revert", json!({})).unwrap(); + press(&mut app, &ctx); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "not reverted"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enter_is_save_and_cmd_r_is_revert() { + let press = |app: &mut EffectcraftApp, ctx: &egui::Context| crate::panels::dialogs::press_key(app, ctx, egui::Key::Enter, egui::Modifiers::NONE); + let (mut app, ctx) = app(); + let dir = std::env::temp_dir().join(format!("ec-unsaved-enter-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Kept.ecproj"); + modified(&mut app); + app.session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + // The Revert prompt discards work: Enter leaves it open. + app.session.execute("layer.newNull", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.revert", json!({})).unwrap(); + press(&mut app, &ctx); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "not reverted"); + // Cmd+R is Revert. + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::R, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none()); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 0, "reverted"); + app.session.execute("layer.newNull", json!({})).unwrap(); + // Save: the change is written, then the project closes. + crate::menus::invoke(&mut app, &ctx, "file.closeProject", json!({})).unwrap(); + press(&mut app, &ctx); + assert!(app.dialog.is_none()); + assert_eq!(app.session.project.comps().count(), 0, "closed"); + app.session.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "the null was saved"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn save_saves_then_continues_and_revert_only_asks_to_discard() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 26b680d1..cbb842d9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -206,6 +206,8 @@ use super::viewer_overlays::{self as ov, VertexHit}; use super::viewer_tools as vt; const HANDLE: f32 = 7.0; +/// Shown when a Roto Brush tool is used in the Composition panel (#753). +const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it."; thread_local! { /// The 3D view camera of the viewer being drawn (None = the comp's active camera). @@ -523,18 +525,22 @@ fn aux_views( return full; } let gap = 2.0; + let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { let mid = full.center().x; - (Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), View3D::Top)]) + ( + Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), + vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), views.pane(0))], + ) } else { let c = full.center(); let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); ( q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), vec![ - (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), View3D::Top), - (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front), - (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right), + (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), views.pane(0)), + (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), views.pane(1)), + (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), views.pane(2)), ], ) }; @@ -543,14 +549,23 @@ fn aux_views( p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); let t = app.session.time(); let (cw, ch) = (comp.width as f32, comp.height as f32); - let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let share = app.session.state.share_view_options; + // The camera and light wireframes of each view (as in After Effects, where every view of the + // layout shows them), evaluated on a snapshot of the project. + let snap_project = app.session.project.clone(); + let snap_expr = app.session.expr.clone(); + let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let selected = app.session.state.selected_layers.clone(); + let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { p.rect_filled(r, 0.0, bg); let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; - let cam = views.cam(v, comp.width as f64, comp.height as f64).state(); + // The Active Camera pane renders through the comp's own camera; the others through their + // view camera. + let through_active = v == View3D::ActiveCamera; + let cam = if through_active { three_d::active_camera(&ectx) } else { views.cam(v, comp.width as f64, comp.height as f64).state() }; let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -562,7 +577,12 @@ fn aux_views( let tex = match cached { Some((k, tex)) if k == key => tex, _ => { - let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() }; + let opts = effectcraft_engine::render::RenderOpts { + scale, + view: (comp.has_3d() && !through_active).then_some(cam), + draft: true, + ..Default::default() + }; let img = app.session.render(cid, t, opts); let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); @@ -578,13 +598,57 @@ fn aux_views( p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); } } + if comp.has_3d() { + let map = ViewerMap { origin: cr.min, zoom: fit, comp: [cw, ch], area: r }; + draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, through_active, Some(v.id())); + } let label = if comp.has_3d() { v.label() } else { crate::i18n::tr("Active Camera") }; p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); app.auto.add(&format!("viewer.view.{}", v.id()), r, label); + if comp.has_3d() + && let Some(chosen) = pane_selector(app, ui, i + 1, r, v) + { + picked = Some((i + 1, chosen)); + } + } + if let Some((pane, v)) = picked + && let Err(e) = app.session.execute("view.setPaneView", json!({"pane": pane, "view": v.id(), "comp": cid.0})) + { + app.ui.status = e.to_string(); } main } +/// The view selector in the top-left corner of pane `pane` (1 to 3) of a multi-view layout, a +/// popup of every 3D view like the toolbar's 3D View menu. Returns the view chosen this frame. +fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option { + let t = app.tokens; + let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0)); + let pid = egui::Id::new(("viewer-pane-view", pane)); + let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid); + if response.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add_response(&format!("viewer.view.{pane}.type"), &response, "View of pane"); + if !widgets::popup_is_open(ui, pid) { + return None; + } + let chosen = widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, View3D::ALL.len(), 0, |ui| { + ui.set_min_width(124.0); + let mut chosen = None; + for (k, v) in View3D::ALL.iter().enumerate() { + let item = ui.selectable_label(*v == current, crate::i18n::tr(v.label())); + app.auto.add(&format!("viewer.view.{pane}.typeItem.{k}"), item.rect, v.label()); + if item.clicked() { + chosen = Some(*v); + } + } + chosen + }); + chosen.filter(|v| *v != current) +} + /// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel /// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { @@ -731,10 +795,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (cw, ch) = (comp.width as f32, comp.height as f32); let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01); + // Fit up to 100% follows the panel's size like Fit does, and stops at 100%. + let fit = if app.ui.viewer.fit_up_to_100 { fit.min(1.0 / ctx.pixels_per_point()) } else { fit }; ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit)); let zoom = app.ui.viewer.zoom.unwrap_or(fit); let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]); - let origin = center - vec2(cw * zoom / 2.0, ch * zoom / 2.0); + // On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel + // instead of being blended with its neighbour (#882). + let ppp = ctx.pixels_per_point(); + let origin = snap_to_pixels(center - vec2(cw * zoom / 2.0, ch * zoom / 2.0), ppp); let map = ViewerMap { origin, zoom, comp: [cw, ch], area }; ctx.data_mut(|d| d.insert_temp(map_id(), map)); let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom)); @@ -744,7 +813,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.viewer.extended = extended_region(app, &comp, cid, map); // Frame. - let ppp = ctx.pixels_per_point(); let scale = app.viewer_scale(zoom, ppp); if !app.playback.playing { // Queue the frame on screen before any prefetch. @@ -1010,8 +1078,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes"); } - super::expr_editor::error_banner(app, ui, area); - // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); @@ -1045,12 +1111,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } + // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer + // instead of leaving a black picture. + let mut notices = 0; + for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { + let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; + let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; + let Some(why) = app.session.footage.problem(f) else { continue }; + if notices >= 3 { + break; + } + let (m, _) = l2c(&ectx, layer); + let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); + let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); + let at = map.to_screen([c.x, c.y]); + let text = format!("{}: {}", layer.name, why); + let galley = painter.layout(text.clone(), Tokens::medium(12.0), Color32::WHITE, (map.area.width() - 40.0).clamp(80.0, 420.0)); + let r = Rect::from_center_size(at + vec2(0.0, notices as f32 * 4.0), galley.size() + vec2(24.0, 16.0)).intersect(map.area); + painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(0x4a, 0x1a, 0x14, 0xe6)); + painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); + painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); + notices += 1; + } + // Free transform box of a path (double-click a path with the Selection tool). let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths); // Cameras and lights (wireframes) and the 3D view name. if comp.has_3d() { - draw_rigs(app, &painter, &map, &ectx, &selected); + let through_active = VIEW_CAM.with(|c| c.get()).is_none(); + draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None); let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); let label = match (view, comp.active_camera(time)) { (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name), @@ -1114,7 +1205,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); let mut context_actions = Vec::new(); let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard"); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { @@ -1132,7 +1223,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if context_layers.is_empty() { ui.label(crate::i18n::tr("No unlocked layer selected")); } else { - super::timeline::layer_menu(ui, &context_layers, &mut context_actions); + let vector = context_layers + .iter() + .filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))) + .any(|layer| super::timeline::vector_footage_layer(&app.session.project, layer)); + super::timeline::layer_menu(ui, &context_layers, vector, &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { @@ -1209,7 +1304,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mods = ui.input(|i| i.modifiers); let space_pan = super::space_hand(&ctx); // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the - // first vertex closes the path. + // first vertex closes the path, pressing an open path's last vertex resumes it. if app.ui.tool == Tool::Pen && !space_pan && resp.hovered() @@ -1425,6 +1520,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(Gesture::Marquee { start: press }) } } + // The Roto Brush paints in the Layer panel only: say so instead of doing nothing. + rt if rt.roto_kind(false).is_some() => { + app.ui.status = crate::i18n::tr(ROTO_HINT).to_string(); + None + } _ => None, }; if let Some(g) = g { @@ -1782,6 +1882,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift})); } } + // The Roto Brush paints in the Layer panel only: select as usual and say so. + rt if rt.roto_kind(false).is_some() => { + if pick(app, &ectx, cpt, mods.shift).is_none() { + let _ = app.session.execute("edit.deselectAll", json!({})); + } + app.ui.status = crate::i18n::tr(ROTO_HINT).to_string(); + } _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); @@ -1813,10 +1920,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); - if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) - && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source - { - app.session.open_comp(*item); + let roto = app.ui.tool.roto_kind(false).is_some(); + if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) { + match &l.source { + effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item), + // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel, + // where the brush works (After Effects). + _ if roto => { + let id = l.id.0; + match crate::menus::invoke(app, &ctx, "layer.openLayer", json!({"layer": id})) { + Ok(_) => app.ui.status.clear(), + Err(e) => app.ui.status = e, + } + } + _ => {} + } } } // 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while @@ -1824,6 +1942,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); // Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures). crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); + // Selected shape gradients' start and end points (#612). + crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); // Status chips (render time / caching). if app.playback.playing && app.playback.waiting { @@ -1834,7 +1954,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { vt::draw_snap(&ctx, &painter, &map, &ectx); vt::rulers(app, ui, &map, outer, area); - vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp); + vt::bottom_bar(app, ui, bar, zoom, time, &comp); if err_h > 0.0 { super::expr_bar::draw(app, ui, err_bar, cid); } @@ -1864,13 +1984,26 @@ fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { /// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen /// through the current 3D view. -fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, selected: &[LayerId]) { +/// Camera and light wireframes seen through `cam` (`through_active`: `cam` is the active camera, +/// which is then not drawn). `pane` names the automation ids: `None` for the main view +/// (`viewer.camera.`), `Some(view)` for a view of a 2- or 4-view layout +/// (`viewer.view..camera.`). +#[allow(clippy::too_many_arguments)] +fn draw_rigs( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + selected: &[LayerId], + cam: CameraState, + through_active: bool, + pane: Option<&str>, +) { use effectcraft_engine::geom::Vec3; use effectcraft_engine::project::{LayerSource, LightKind}; - let cam = cam_state(ectx); let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64); let active = ectx.comp.active_camera(ectx.time).map(|c| c.id); - let through_active = VIEW_CAM.with(|c| c.get()).is_none(); + let prefix = pane.map(|v| format!("viewer.view.{v}.")).unwrap_or_else(|| "viewer.".to_string()); let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y])); let seg = |a: Vec3, b: Vec3, stroke: Stroke| { if let (Some(x), Some(y)) = (proj(a), proj(b)) { @@ -1907,7 +2040,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } if let Some(s) = proj(eye) { - app.auto.add(&format!("viewer.camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); + app.auto.add(&format!("{prefix}camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); } } else { let LayerSource::Light { kind } = l.source else { continue }; @@ -1928,16 +2061,16 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, painter.circle_stroke(q, 3.0, stroke); } } - app.auto.add(&format!("viewer.light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); + app.auto.add(&format!("{prefix}light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); } } } /// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), -/// add a vertex on a visible path's segment, or start a new path: a mask on the selected -/// footage/solid layer (or shape layer, with Tool Creates Mask), a shape path (Shape n group with -/// the Tools bar's Fill and Stroke) on the selected shape layer, or a new shape layer when -/// nothing is selected. The placed point snaps. +/// resume an open path whose last vertex is pressed, add a vertex on a visible path's segment, or +/// start a new path: a mask on the selected footage/solid layer (or shape layer, with Tool Creates +/// Mask), a shape path (Shape n group with the Tools bar's Fill and Stroke) on the selected shape +/// layer, or a new shape layer when nothing is selected. The placed point snaps. /// Add a Position keyframe at a motion path's frame, keeping the path where it is. fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { @@ -2023,6 +2156,11 @@ fn pen_press( } return; } + // Clicking the last vertex of an open path (e.g. after Esc ended it) resumes drawing it. + if let Some(cur) = ov::open_end_at(paths, map, pos, 8.0) { + ui.data_mut(|d| d.insert_temp(pen_id(), cur)); + return; + } // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. if let Some(f) = motion_frame { add_motion_key(app, f); @@ -2350,21 +2488,32 @@ pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Strok } } -/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. +/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point. +pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 { + if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) { + return p; + } + pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp) +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in +/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels +/// as blocks, never smoothed, in either mode (#882). pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { - if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } + if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST } } /// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters /// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of -/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. +/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both +/// show the nearest pixel (#882). pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; let label = Some("viewer frame"); if smooth { SamplerDescriptor { label, - mag_filter: FilterMode::Linear, + mag_filter: FilterMode::Nearest, min_filter: FilterMode::Linear, mipmap_filter: MipmapFilterMode::Linear, ..Default::default() @@ -2425,9 +2574,9 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: FrameImage::Cpu(img) => { // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); + let fitted = crate::frames::fit_shared_texture_by(&img, max, minify); match &mut app.viewer_tex { - Some((tex, k, m)) if tex.size() == fitted.size => { + Some((tex, k, m)) if tex.size() == fitted.size() => { tex.set(fitted, opts); (*k, *m) = (key, minify); } @@ -2462,6 +2611,35 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps + /// comp pixels 1:1 onto screen pixels. + #[test] + fn frame_origin_snaps_to_physical_pixels() { + for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] { + for p in [pos2(-201.12, 72.4), pos2(689.8, 239.75), pos2(0.3, -0.37)] { + let s = snap_to_pixels(p, ppp); + for v in [s.x * ppp, s.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}: {p:?} → {s:?}"); + } + assert!((s - p).length() <= 0.71 / ppp, "moved by at most half a pixel: {p:?} → {s:?}"); + } + } + assert_eq!(snap_to_pixels(pos2(1.3, 2.0), 0.0), pos2(1.3, 2.0)); + } + + /// #882: a magnified frame shows its pixels as blocks in both Zoom Quality modes; More + /// Accurate only smooths when the frame is shown smaller. + #[test] + fn magnified_frames_are_never_smoothed() { + use eframe::wgpu::FilterMode; + for smooth in [true, false] { + assert_eq!(zoom_texture_options(smooth).magnification, egui::TextureFilter::Nearest); + assert_eq!(zoom_sampler(smooth).mag_filter, FilterMode::Nearest); + } + assert_eq!(zoom_texture_options(true).minification, egui::TextureFilter::Linear); + assert_eq!(zoom_sampler(true).min_filter, FilterMode::Linear); + } + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). fn tilted() -> Mat3 { Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 54218e9f..cc901302 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -87,6 +87,15 @@ pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f3 best.map(|b| b.1) } +/// The open path whose last vertex lies within `tol` points of `pos`: the Pen resumes it there. +pub(crate) fn open_end_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64)> { + paths.iter().find_map(|p| { + let v = p.sp.vertices.last().filter(|_| !p.sp.closed)?; + let q = p.m.apply(gv2(v[0], v[1])); + (map.to_screen([q.x, q.y]).distance(pos) < tol).then_some((p.layer, p.uid)) + }) +} + /// A mask or shape path of a layer by group uid, evaluated at the context time. pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option { let l = ectx.comp.layer(layer)?; @@ -640,3 +649,26 @@ pub(crate) fn update( Drag::Roi { .. } => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo { + let n = vertices.len(); + let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() }; + PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } + } + + /// #912: clicking the last vertex of an open path with the Pen resumes that path. + #[test] + fn pen_resumes_an_open_path_at_its_last_vertex() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [100.0, 100.0], area: Rect::EVERYTHING }; + let open = info(vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]], false); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(52.0, 49.0), 8.0), Some((LayerId(3), 7))); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(10.0, 10.0), 8.0), None, "first vertex"); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(80.0, 80.0), 8.0), None, "far away"); + let closed = info(vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]], true); + assert_eq!(open_end_at(&[closed], &map, egui::pos2(50.0, 50.0), 8.0), None, "closed path"); + } +} diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 36a3c7f2..f4604f2e 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -26,6 +26,7 @@ use serde_json::{Value, json}; use super::viewer::ViewerMap; use crate::EffectcraftApp; +use crate::dock::PanelKind; use crate::state::Tool; /// Pointer interaction owned by text editing (kept across frames while the button is down). @@ -135,6 +136,21 @@ fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option { } } +/// The panels a text edit stays open across: the viewers, and Character, Paragraph and +/// Properties, which format the selected characters. +fn keeps_text_edit(p: PanelKind) -> bool { + super::viewers::id_of(p).is_some() || matches!(p, PanelKind::Character | PanelKind::Paragraph | PanelKind::Properties) +} + +/// A click in panel `p`. Any other panel (the Timeline, the Project panel, …) ends the edit, as a +/// click outside the text does, and gives the keyboard back to the app (#831). +pub fn panel_clicked(app: &mut EffectcraftApp, ctx: &egui::Context, p: PanelKind) { + if app.session.state.text_edit.is_some() && !keeps_text_edit(p) { + exec(app, "text.endEdit", json!({})); + ctx.memory_mut(|m| m.surrender_focus(focus_id())); + } +} + /// Typing merge key: consecutive keystrokes fold into one undo step until the caret moves. fn typing_key(ui: &egui::Ui, bump: bool) -> String { let id = egui::Id::new("viewer-text-typing"); @@ -332,16 +348,18 @@ pub fn hook( ui.memory_mut(|m| { m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); }); - // A session started elsewhere (menu, agent, Properties) takes the keyboard once. + // A session started elsewhere (menu, agent, Properties) takes the keyboard once… let seen = egui::Id::new("viewer-text-seen"); let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0); - // …and gets it back whenever nothing else holds it (after a panel click, say). - if ui.data(|d| d.get_temp::>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) { + // …and gets it back when a click in the viewer or a formatting panel left nothing holding + // it; not merely because nothing has the focus (#831). + let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused); + if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain { ui.data_mut(|d| d.insert_temp(seen, key)); ui.memory_mut(|m| m.request_focus(focus_id())); } if ui.memory(|m| m.has_focus(focus_id())) { - keyboard(app, ui); + keyboard(app, ui, resp.rect); } } else { ui.data_mut(|d| d.insert_temp::>(egui::Id::new("viewer-text-seen"), None)); @@ -359,12 +377,16 @@ pub fn hook( owns } -fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) { +/// This frame's keys, in order, into the edited text, up to a press outside the `viewer`: the +/// panel it lands in decides (after this frame's panels) whether the edit ends, so the keys after +/// it in this frame are neither typed nor run as shortcuts (#831). +fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { let events = ui.input(|i| i.events.clone()); let ctx = ui.ctx().clone(); for ev in events { let Some(sel) = app.session.state.text_edit.clone() else { return }; match ev { + egui::Event::PointerButton { pos, pressed: true, .. } if !viewer.contains(pos) => return, egui::Event::Text(t) if !t.chars().any(char::is_control) => { let k = typing_key(ui, false); exec(app, "text.insert", json!({"text": t, "merge": k})); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 772d0769..fa198505 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -403,7 +403,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: _ => { let img = crate::frames::to_color_image(&s.image); let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) }; - let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR); + let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } @@ -509,6 +510,53 @@ fn toggle_popup(ui: &egui::Ui, id: &str) { ui.data_mut(|d| d.insert_temp(pid, !open)); } +/// A magnification ratio dropdown (the Composition and Layer panels' footers): `zoom` is the +/// shown magnification and `current` the stored one (None = fit), both in points per pixel. +/// Returns the chosen magnification (`Some(None)` = Fit). +#[allow(clippy::too_many_arguments)] +pub(crate) fn magnification_menu( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + r: Rect, + id: &str, + auto_id: &str, + auto_items: &str, + zoom: f32, + current: Option, + fit_up: bool, +) -> Option<(Option, bool)> { + let t = app.tokens; + let ppp = ui.ctx().pixels_per_point(); + let pop = format!("{id}-pop"); + let response = + widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new(id)).on_hover_text(crate::i18n::tr("Magnification ratio popup")); + if response.clicked() { + toggle_popup(ui, &pop); + } + app.auto.add_response(auto_id, &response, "Magnification"); + const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; + let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; + let items: Vec<(String, bool)> = MAGS + .iter() + .map(|m| { + let on = match *m { + "Fit" => current.is_none() && !fit_up, + "Fit up to 100%" => current.is_none() && fit_up, + s => s.trim_end_matches('%').parse::().is_ok_and(|v| current.is_some() && (v - cur).abs() < 0.06), + }; + (m.to_string(), on) + }) + .collect(); + let i = popup(app, ui, &pop, r, &items, auto_items)?; + // `(zoom, fit up to 100 %)`: Fit and Fit up to 100% leave the zoom unset (it follows the + // panel size). + match *MAGS.get(i)? { + "Fit" => Some((None, false)), + "Fit up to 100%" => Some((None, true)), + s => s.trim_end_matches('%').parse::().ok().map(|v| (Some(v / 100.0 / ppp), false)), + } +} + /// Magnification as After Effects shows it: "136.1 %", "100 %". pub(crate) fn magnification_label(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") } @@ -538,7 +586,7 @@ pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { /// transparency grid, mask and shape path visibility, region of interest, grid and guide /// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D /// renderer and view, and the current time. -pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) { +pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) { let t = app.tokens; let p = ui.painter().clone(); p.rect_filled(bar, 0.0, t.panel_bg); @@ -554,7 +602,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, egui::ScrollArea::horizontal().id_salt("viewer-controls").auto_shrink([false, false]).show(ui, |ui| { let row = Rect::from_min_size(ui.max_rect().min, vec2(bar.width(), 30.0)); let first_control = app.auto.elements.len(); - let width = bottom_bar_contents(app, ui, row, zoom, fit, time, comp); + let width = bottom_bar_contents(app, ui, row, zoom, time, comp); ui.allocate_rect(Rect::from_min_size(row.min, vec2(width, row.height())), Sense::hover()); if let Some(focused) = ui.ctx().memory(|memory| memory.focused()) && app.auto.elements.get(first_control..).is_some_and(|controls| controls.iter().any(|control| control.response_id == Some(focused))) @@ -567,7 +615,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, }); } -fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) -> f32 { +fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) -> f32 { let t = app.tokens; let p = ui.painter().clone(); let cy = bar.center().y; @@ -576,44 +624,24 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z // Magnification. let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0)); - let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")) - .on_hover_text(crate::i18n::tr("Magnification ratio popup")); - if response.clicked() { - toggle_popup(ui, "vw-mag-pop"); - } - app.auto.add_response("viewer.magnification", &response, "Magnification"); - const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; - let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; - let items: Vec<(String, bool)> = MAGS - .iter() - .map(|m| { - let on = match *m { - "Fit" => app.ui.viewer.zoom.is_none(), - s => s.trim_end_matches('%').parse::().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06), - }; - (m.to_string(), on) - }) - .collect(); - if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") { + let fit_up = app.ui.viewer.fit_up_to_100; + if let Some((z, up)) = magnification_menu(app, ui, r, "vw-mag", "viewer.magnification", "magnification", zoom, app.ui.viewer.zoom, fit_up) { app.ui.viewer.pan = [0.0, 0.0]; - app.ui.viewer.zoom = match MAGS[i] { - "Fit" => None, - "Fit up to 100%" => Some(fit.min(1.0 / ppp)), - s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), - }; + app.ui.viewer.fit_up_to_100 = up; + app.ui.viewer.zoom = z; } x = r.max.x + 4.0; // Resolution / down-sample factor. let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0)); let scale = app.viewer_scale(zoom, ppp); - let response = widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res")) + let res = app.viewer_res(); + let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::new("vw-res")) .on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup")); if response.clicked() { toggle_popup(ui, "vw-res-pop"); } app.auto.add_response("viewer.resolution", &response, "Resolution/Down Sample Factor"); - let res = app.ui.viewer.res; let items: Vec<(String, bool)> = vec![ ("Full".into(), res == Resolution::Full), ("Half".into(), res == Resolution::Half), diff --git a/crates/ui-egui/src/panels/waveform.rs b/crates/ui-egui/src/panels/waveform.rs index ab3fbd79..6fc42ed3 100644 --- a/crates/ui-egui/src/panels/waveform.rs +++ b/crates/ui-egui/src/panels/waveform.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_engine::project::{ItemId, ItemKind, Layer, LayerSource, Project, Property}; +use effectcraft_engine::render::audio::db_to_gain; use effectcraft_engine::time::Tick; use egui::{Color32, Painter, Rect, Stroke, pos2}; @@ -75,6 +76,17 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio None } +/// Left/right gain of the layer's Audio Levels (dB) at comp time `t`; unity without the property. +fn level_gains(levels: Option<&Property>, t: Tick) -> [f32; 2] { + match levels { + Some(p) => { + let [l, r] = p.value_at(t).as_vec2(); + [db_to_gain(l), db_to_gain(r)] + } + None => [1.0, 1.0], + } +} + /// Draw `layer`'s waveform in `rect` (left channel above right), columns mapped through the /// timeline's time map and the layer's time mapping, limited to its [in, out) span. pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summary, color: Color32) { @@ -83,6 +95,7 @@ pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summar let x0 = tm.x(layer.in_point.seconds()).max(rect.min.x).floor() as i32; let x1 = tm.x(layer.out_point.seconds()).min(rect.max.x).ceil() as i32; let n = s.peaks.len() as i64; + let levels = layer.props.sub("audio").and_then(|g| g.get("levels")); for c in 0..2 { p.line_segment([pos2(x0 as f32, mid[c]), pos2(x1 as f32, mid[c])], Stroke::new(1.0, color.gamma_multiply(0.5))); } @@ -101,10 +114,26 @@ pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summar agg[2] = agg[2].min(b[2]); agg[3] = agg[3].max(b[3]); } + let gain = level_gains(levels, Tick::from_seconds_f64((ta + tb) * 0.5)); for c in 0..2 { - let (lo, hi) = (agg[c * 2].clamp(-1.0, 1.0), agg[c * 2 + 1].clamp(-1.0, 1.0)); + let (lo, hi) = ((agg[c * 2] * gain[c]).clamp(-1.0, 1.0), (agg[c * 2 + 1] * gain[c]).clamp(-1.0, 1.0)); let (ya, yb) = (mid[c] - hi * half, mid[c] - lo * half); p.line_segment([pos2(x as f32 + 0.5, ya), pos2(x as f32 + 0.5, yb.max(ya + 1.0))], Stroke::new(1.0, color)); } } } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::project::Value; + + #[test] + fn waveform_follows_audio_levels() { + let p = Property::new(1, "levels", "Audio Levels", Value::Vec2([-6.0206, -96.0])); + let g = level_gains(Some(&p), Tick::ZERO); + assert!((g[0] - 0.5).abs() < 1e-3, "{}", g[0]); + assert_eq!(g[1], 0.0); + assert_eq!(level_gains(None, Tick::ZERO), [1.0, 1.0]); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..d0a23279 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -224,58 +224,19 @@ impl Tool { } } -/// Viewer resolution (Auto follows the magnification). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Resolution { - #[default] - Auto, - Full, - Half, - Third, - Quarter, - /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). - Custom(u8), -} - -impl Resolution { - pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; - pub fn label(self) -> &'static str { - match self { - Resolution::Auto => "Auto", - Resolution::Full => "Full", - Resolution::Half => "Half", - Resolution::Third => "Third", - Resolution::Quarter => "Quarter", - Resolution::Custom(_) => "Custom", - } - } - /// Render scale for a viewer magnification (and display pixel density). - pub fn scale(self, zoom: f32, ppp: f32) -> f64 { - match self { - // As in After Effects, Auto renders the pixels the magnification needs: the coarsest - // whole factor that still gives every screen pixel a rendered one (Full above 50 %, - // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). - Resolution::Auto => { - let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); - if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } - } - Resolution::Full => 1.0, - Resolution::Half => 0.5, - Resolution::Third => 1.0 / 3.0, - Resolution::Quarter => 0.25, - Resolution::Custom(n) => 1.0 / n.max(1) as f64, - } - } -} +/// The viewer's Resolution: each composition keeps its own in the project +/// ([`effectcraft_engine::project::Comp::resolution`], `EffectcraftApp::viewer_res`). +pub use effectcraft_engine::project::Resolution; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct ViewerState { /// Magnification (1 = 100%); None = fit. pub zoom: Option, + /// Fit up to 100%: while fitting (`zoom` is None), the comp is never magnified past 100%. + pub fit_up_to_100: bool, /// Pan offset in points from the centred position. pub pan: [f32; 2], - pub res: Resolution, pub transparency_grid: bool, pub show_masks: bool, pub safe_margins: bool, @@ -313,8 +274,8 @@ impl Default for ViewerState { fn default() -> Self { ViewerState { zoom: None, + fit_up_to_100: false, pan: [0.0, 0.0], - res: Resolution::Auto, transparency_grid: false, show_masks: true, safe_margins: false, @@ -488,6 +449,15 @@ pub struct FxPick { pub name: String, } +/// A Character panel Fill or Stroke eyedropper waiting for a Composition viewer click. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextColorPick { + pub layer: u64, + pub field: String, + /// Preserve the edited character range when focus moves from the panel to the viewer. + pub range: Option<[usize; 2]>, +} + /// Effects & Presets contents-menu options. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EffectsView { @@ -566,6 +536,10 @@ pub struct UiState { /// Floating panels of the saved workspaces. #[serde(default)] pub saved_floating: std::collections::BTreeMap>, + /// Floating groups shown in their own native window instead of over the dock (Move Panel + /// to New Window), so they can be placed on another monitor. + #[serde(default)] + pub panel_windows: Vec, /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, @@ -615,6 +589,9 @@ pub struct UiState { /// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click. #[serde(default)] pub fx_pick: Option, + /// Character panel colour eyedropper waiting for a viewer click. + #[serde(default)] + pub text_color_pick: Option, /// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid. #[serde(default)] pub fx_curve_channel: BTreeMap, @@ -655,6 +632,14 @@ pub struct UiState { /// Layer panel View: number of effects rendered (None = through the last Paint effect). #[serde(default)] pub layer_view: Option, + /// Layer panel magnification in points per layer pixel (None = fit), set by its + /// magnification menu, View ▸ Zoom In / Zoom Out while it is focused, the mouse wheel and the + /// Zoom tool. + #[serde(default)] + pub layer_zoom: Option, + /// Layer panel pan offset in points from the centred position (Hand tool, Space-drag). + #[serde(default)] + pub layer_pan: [f32; 2], /// Wiggler / Smoother / Motion Sketch settings. #[serde(default)] pub anim_tools: AnimToolsState, @@ -680,6 +665,7 @@ impl Default for UiState { saved_workspaces: Default::default(), floating: vec![], saved_floating: Default::default(), + panel_windows: vec![], maximized: None, locked_tabs: BTreeSet::new(), viewers: Default::default(), @@ -702,6 +688,7 @@ impl Default for UiState { fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), fx_pick: None, + text_color_pick: None, fx_curve_channel: BTreeMap::new(), fx_curve_pencil: BTreeSet::new(), fx_levels_channel: BTreeMap::new(), @@ -717,6 +704,8 @@ impl Default for UiState { tracker_source: None, layer_panel: None, layer_view: None, + layer_zoom: None, + layer_pan: [0.0, 0.0], anim_tools: AnimToolsState::default(), mini_flowchart: None, flowchart: Default::default(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 953bf66d..abc6f576 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -340,6 +340,13 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); + // Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which + // would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack. + for (family, stack) in &mut fonts.families { + if *family != FontFamily::Monospace { + stack.push("jbmono".into()); + } + } install_script_fallbacks(&mut fonts, language); ctx.set_fonts(fonts); apply_visuals(ctx, t); @@ -649,6 +656,19 @@ mod tests { install_script_fallbacks(&mut english, "en"); } + #[test] + fn interface_symbols_have_a_glyph_in_every_family() { + let ctx = egui::Context::default(); + install(&ctx, &Tokens::for_kind(Default::default()), "en"); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { + let id = FontId::new(12.0, family); + for c in "▸•›·▶◀●○⚠…×".chars() { + assert!(ctx.fonts_mut(|f| f.has_glyph(&id, c)), "{c} has no glyph in {id:?}"); + } + } + } + #[test] fn hangul_gets_a_face_when_one_is_installed() { use effectcraft_text::fonts; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fcb4a4bc..c005224d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -194,6 +194,24 @@ pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { } } +/// Put the keyboard on a dialog's first text field the frame the dialog opens, with its whole +/// `text` selected, so typing replaces the value without a click. Unlike [`keep_focus`] the +/// focus is the user's afterwards. +pub fn focus_on_open(ctx: &egui::Context, field: &Response, text: &str) { + // The dialog is open in consecutive passes; a gap means it was closed and opened again. + let shown = field.id.with("focus-on-open-pass"); + let pass = ctx.cumulative_pass_nr(); + let previous = ctx.data_mut(|d| { + let previous = d.get_temp::(shown); + d.insert_temp(shown, pass); + previous + }); + if previous.is_none_or(|p| p.saturating_add(1) < pass) { + select_all(ctx, field.id, text); + field.request_focus(); + } +} + /// Select all of the text field `id`'s `text`, so typing replaces it. pub fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); @@ -314,6 +332,11 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea /// Dropdown-looking control ("Full ▾"). pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -> Response { + dropdown_tip(ui, rect, text, None, t, id) +} + +/// [`dropdown`] with a tooltip `tip` (shown always; it replaces the elided text's tooltip). +pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &Tokens, id: egui::Id) -> Response { let resp = ui.interact(rect, id, Sense::click()); ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); @@ -335,6 +358,9 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) - }; ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + if let Some(tip) = tip { + return resp.on_hover_text(tip.to_string()); + } if text.chars().count() > 0 && !fits(text) { return resp.on_hover_text(text.to_string()); } @@ -586,6 +612,27 @@ pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner } +/// egui's menu style with no gap between the rows: the gap the app's item spacing left between +/// two entries highlighted neither and swallowed a click there. The rows grow by that gap, so a +/// menu keeps its size. +pub fn menu_style(style: &mut egui::Style) { + egui::containers::menu::menu_style(style); + style.spacing.interact_size.y += style.spacing.item_spacing.y; + style.spacing.item_spacing.y = 0.0; +} + +/// `resp`'s context menu in the app's [`menu_style`] (its submenus inherit it). +pub fn context_menu(resp: &Response, add_contents: impl FnOnce(&mut Ui)) -> Option> { + egui::Popup::context_menu(resp).style(menu_style).show(add_contents) +} + +/// A button opening a menu in the app's [`menu_style`], for one outside the menu bar. +pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, add_contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + let config = egui::containers::menu::MenuConfig::new().style(menu_style); + let (response, inner) = egui::containers::menu::MenuButton::new(atoms).config(config).ui(ui, add_contents); + egui::InnerResponse::new(inner.map(|i| i.inner), response) +} + /// Whether a [`popup_menu`] is open (build its options only then: they can be long). pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false)) diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 9262266d..b7233a80 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use effectcraft_engine::config::ConfigStore; use serde::{Deserialize, Serialize}; -use crate::dock::{DockNode, Floating, PanelKind, SplitSize}; +use crate::dock::{DockNode, Floating, PanelKind, PanelWindow, SplitSize}; use crate::state::UiState; const FILE: &str = "workspaces.json"; @@ -28,6 +28,9 @@ struct Snapshot { maximized: Option, saved_workspaces: BTreeMap, saved_floating: BTreeMap>, + /// Floating groups in their own native window (absent from files of earlier versions). + #[serde(default)] + panel_windows: Vec, } impl Snapshot { @@ -41,6 +44,7 @@ impl Snapshot { maximized: ui.maximized, saved_workspaces: ui.saved_workspaces.clone(), saved_floating: ui.saved_floating.clone(), + panel_windows: ui.panel_windows.clone(), } } @@ -52,6 +56,14 @@ impl Snapshot { ui.maximized = self.maximized; ui.saved_workspaces = self.saved_workspaces; ui.saved_floating = self.saved_floating; + // A window is only kept for a panel that still floats, and only where a window can be. + let floating = |panel: PanelKind| ui.floating.iter().any(|group| group.panels.contains(&panel)); + ui.panel_windows = self + .panel_windows + .into_iter() + .filter(|window| floating(window.panel)) + .map(|window| PanelWindow { panel: window.panel, rect: PanelWindow::sanitized(window.rect) }) + .collect(); // Reserve restored viewer slots without restoring any project-specific composition // links. New Viewer allocates from this map, including after switching named layouts. let mut panels = Vec::new(); @@ -89,7 +101,7 @@ impl Snapshot { PanelKind::Viewer(id) => (1..=MAX_VIEWER_ID).contains(&id), _ => true, }; - if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) { + if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) || self.panel_windows.iter().any(|w| !panel_ok(w.panel)) { return Err("workspace viewer ID is outside the supported range 1–65535".into()); } let mut nodes = 0usize; @@ -134,6 +146,9 @@ impl Snapshot { } } } + if self.panel_windows.len() > MAX_PANELS || self.panel_windows.iter().any(|w| !w.rect.iter().all(|v| v.is_finite())) { + return Err("invalid workspace panel windows".into()); + } for groups in std::iter::once(&self.floating).chain(self.saved_floating.values()) { if groups.len() > MAX_PANELS { return Err("too many floating workspace groups".into()); @@ -229,6 +244,7 @@ impl WorkspaceStore { || self.saved.maximized != ui.maximized || self.saved.saved_workspaces != ui.saved_workspaces || self.saved.saved_floating != ui.saved_floating + || self.saved.panel_windows != ui.panel_windows } pub(crate) fn save(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result { diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 0c65084b..876035a9 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -3,6 +3,7 @@ //! --test ui_3d -- --ignored`); the other test only runs the UI logic. use effectcraft_engine::Session; +use effectcraft_engine::render::three_d::View3D; use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; @@ -195,3 +196,72 @@ fn extended_viewer_renders_past_the_comp_frame() { h.step(); assert!(h.state().ui.viewer.extended.is_none()); } + +/// #875: the Top / Front / Right views of a 4-view layout draw the camera wireframe (they drew +/// none, so moving the camera showed nothing there), and it follows the camera's Position. +#[test] +fn layout_views_draw_the_camera_and_follow_its_position() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("view.layout", json!({"views": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + let centre = |h: &Harness<'_, EffectcraftApp>, view: &str| { + let id = format!("viewer.view.{view}.camera.{cam}"); + let [x, y, w, hh] = h.state().auto.find(&id).unwrap_or_else(|| panic!("{id} is registered")).rect; + (x + w / 2.0, y + hh / 2.0) + }; + for v in ["top", "front", "right"] { + centre(&h, v); + } + let before = centre(&h, "top"); + h.state_mut().session.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [480, 180, -300]})).unwrap(); + settle(&mut h); + let after = centre(&h, "top"); + assert!(after.0 - before.0 > 10.0, "the camera moves right in the Top view: {before:?} -> {after:?}"); + assert!((after.1 - before.1).abs() < 1.0, "{before:?} -> {after:?}"); +} + +/// A 4-view layout over a comp with a 3D solid and a camera. +fn four_views() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap(); + s.execute("view.layout", json!({"views": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + h +} + +/// #670: every pane of a multi-view layout has its own view selector; choosing a view changes +/// that pane (its camera and its wireframes) and no other, and it is remembered for the comp. +#[test] +fn each_layout_pane_chooses_its_own_view() { + let mut h = four_views(); + let present = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.find(&format!("viewer.view.{view}")).is_some(); + let has_camera = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.query(&format!("viewer.view.{view}.camera.")).len() == 1; + for p in 1..=3 { + assert!(h.state().auto.find(&format!("viewer.view.{p}.type")).is_some(), "pane {p} has a selector"); + } + assert!(present(&h, "front") && has_camera(&h, "front")); + // Pane 2 (the Front view) becomes the Left view. + let left = View3D::ALL.iter().position(|v| *v == View3D::Left).unwrap(); + click_id(&mut h, "viewer.view.2.type"); + click_id(&mut h, &format!("viewer.view.2.typeItem.{left}")); + settle(&mut h); + assert_eq!(h.state().auto.find("viewer.view.2.type").unwrap().label, "View of pane"); + assert!(present(&h, "left") && has_camera(&h, "left"), "pane 2 shows the Left view with its wireframe"); + assert!(!present(&h, "front"), "pane 2 no longer shows Front"); + assert!(present(&h, "top") && present(&h, "right"), "the other panes keep their views"); + let cid = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.state.views3d[&cid].pane(1), View3D::Left); + // The Active Camera view of a pane does not draw the camera it looks through. + h.state_mut().session.execute("view.setPaneView", json!({"pane": 1, "view": "activeCamera"})).unwrap(); + settle(&mut h); + assert!(present(&h, "activeCamera") && !has_camera(&h, "activeCamera")); +} diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 561f50a0..fcbf3d09 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,6 +1,6 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; -use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable as _}; use serde_json::json; @@ -132,6 +132,23 @@ fn timeline_blank_menu_opens_composition_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); } +#[test] +fn timeline_blank_menu_opens_current_comp_in_essential_graphics() { + for graph in [false, true] { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let area = rect(&h, "timeline.layerMarquee"); + let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() }; + right_click(&mut h, p); + let target = if graph { "timeline.context.graph.essentialGraphics" } else { "timeline.context.outline.essentialGraphics" }; + assert!(h.state().auto.find(target).is_some()); + h.get_by_label("Open in Essential Graphics").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.essential_primary, Some(comp)); + assert!(h.state().ui.dock.contains(PanelKind::EssentialGraphics)); + } +} + /// #407: right-clicking a property's name in the Timeline opens After Effects' property menu: /// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or /// Remove Expression and Add Property to Essential Graphics. @@ -195,3 +212,17 @@ fn timeline_property_names_have_the_property_menu() { choose(&mut h, scale, "editValue"); assert_eq!(h.state().dialog, Some(Dialog::Form)); } + +/// The rows of a context menu touch: a gap between two entries highlighted neither and +/// swallowed a click there. +#[test] +fn context_menu_rows_leave_no_gap_between_them() { + let mut h = harness(); + let p = rect(&h, "project.empty").center(); + right_click(&mut h, p); + let rows = ["newComp", "newFolder", "import"].map(|key| rect(&h, &format!("project.context.{key}"))); + for pair in rows.windows(2) { + assert!((pair[1].top() - pair[0].bottom()).abs() < 0.5, "gap between {:?} and {:?}", pair[0], pair[1]); + } + snapshot(&mut h, "project-rows"); +} diff --git a/crates/ui-egui/tests/ui_character_eyedropper.rs b/crates/ui-egui/tests/ui_character_eyedropper.rs new file mode 100644 index 00000000..a2c4c6d1 --- /dev/null +++ b/crates/ui-egui/tests/ui_character_eyedropper.rs @@ -0,0 +1,96 @@ +//! Character Fill and Stroke can sample a displayed Composition pixel. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{ + EffectcraftApp, + dock::{DockNode, PanelKind, SplitSize}, + panels::viewer::comp_to_screen, +}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn click_auto(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + click(h, pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5)); +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + h.run_steps(4); +} + +#[test] +fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Background", "color": "#20c040"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": text})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.set_workspace("Text"); + let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + settle(&mut h); + click_auto(&mut h, "character.fill.eyedropper"); + assert_eq!(h.state().ui.text_color_pick.as_ref().unwrap().range, Some([0, 5])); + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + assert!(h.state().ui.text_color_pick.is_none()); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x20 as f32 / 255.0, 0xc0 as f32 / 255.0, 0x40 as f32 / 255.0]; + for (actual, expected) in doc.style_at(0).fill.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } + assert_ne!(doc.style_at(8).fill, doc.style_at(0).fill, "unselected text keeps its colour"); +} + +#[test] +fn character_stroke_eyedropper_samples_viewer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "brat", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0], "size": 32.0})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedB(360.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Character], active: 0 }), + }; + let builder = Harness::builder().with_size(vec2(1200.0, 760.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + h.state_mut().show_panel(PanelKind::Composition); + settle(&mut h); + click_auto(&mut h, "character.stroke.eyedropper"); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); + } + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x8a as f32 / 255.0, 0xce as f32 / 255.0, 0.0]; + for (actual, expected) in doc.stroke.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } +} diff --git a/crates/ui-egui/tests/ui_comp_presets.rs b/crates/ui-egui/tests/ui_comp_presets.rs new file mode 100644 index 00000000..0b0b3072 --- /dev/null +++ b/crates/ui-egui/tests/ui_comp_presets.rs @@ -0,0 +1,85 @@ +//! Composition Settings: saving, picking and deleting your own presets (egui_kittest). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + // The automation registry still holds the previous frame's elements: one more frame. + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn open_settings(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "comp.settings", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); +} + +fn preset_names(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.prefs.comp_presets.iter().map(|q| q.name.clone()).collect() +} + +/// Save the comp's size and rate as a preset (Enter saves without pressing OK), then delete it. +#[test] +fn composition_settings_save_and_delete_your_own_preset() { + let mut h = harness(); + open_settings(&mut h); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none(), "nothing of yours to delete yet"); + click(&mut h, "dialog.comp.presetSave"); + // Choose Name opens with the keyboard in its field. + h.input_mut().events.push(Event::Text("1440p 60".into())); + h.run_steps(2); + key(&mut h, Key::Enter); + assert_eq!(preset_names(&h), ["1440p 60"]); + let q = &h.state().session.prefs.comp_presets[0]; + assert_eq!((q.width, q.height, q.pixel_aspect, q.frame_rate), (2560, 1440, 1.0, 60.0)); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings), "Enter in the name field doesn't press OK"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none(), "the name row closes"); + // The comp's values are now that preset, so it can be deleted. + click(&mut h, "dialog.comp.presetDelete"); + assert!(preset_names(&h).is_empty()); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none()); +} + +/// A built-in preset's name can't be taken; Cancel closes the name row without saving. +#[test] +fn a_preset_name_cannot_be_a_built_in_one() { + let mut h = harness(); + open_settings(&mut h); + click(&mut h, "dialog.comp.presetSave"); + click(&mut h, "dialog.comp.presetName"); + h.input_mut().events.push(Event::Text("HDTV 1080 24".into())); + h.run_steps(2); + click(&mut h, "dialog.comp.presetNameSave"); + assert!(preset_names(&h).is_empty(), "the built-in name is refused"); + click(&mut h, "dialog.comp.presetNameCancel"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none()); + assert!(preset_names(&h).is_empty()); +} diff --git a/crates/ui-egui/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/ui_control_modifiers.rs new file mode 100644 index 00000000..46567aed --- /dev/null +++ b/crates/ui-egui/tests/ui_control_modifiers.rs @@ -0,0 +1,62 @@ +//! Control-channel clicks and drags hold their modifiers for the whole gesture: egui takes +//! `i.modifiers` from `ModifiersChanged` events, not from the pointer events, and synthetic +//! events arrive one per frame, so a Shift-click must hold Shift from its press to its release. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::control::{self, ControlRequest}; +use serde_json::{Value, json}; + +/// Feed the queued synthetic input frame by frame; per frame: (has a press/release, the +/// modifiers egui holds in that frame, which only `ModifiersChanged` events change). +fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> { + let mut out = vec![]; + let mut held = egui::Modifiers::NONE; + for _ in 0..64 { + let mut raw = egui::RawInput::default(); + app.feed_synthetic(&mut raw); + if raw.events.is_empty() { + break; + } + let mut button = false; + for e in &raw.events { + match e { + egui::Event::ModifiersChanged(m) => held = *m, + egui::Event::PointerButton { .. } => button = true, + _ => {} + } + } + out.push((button, held)); + } + out +} + +fn send(app: &mut EffectcraftApp, method: &str, params: Value) { + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new(method, params); + let _ = control::handle(app, &ctx, &req); +} + +#[test] +fn shift_click_holds_shift_from_press_to_release() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "modifiers": {"shift": true}})); + let f = frames(&mut app); + let buttons: Vec<_> = f.iter().filter(|(b, _)| *b).collect(); + assert_eq!(buttons.len(), 2, "press and release: {f:?}"); + assert!(buttons.iter().all(|(_, m)| m.shift), "Shift on the press and the release: {f:?}"); + // Afterwards the modifiers are released again. + send(&mut app, "ui.click", json!({"x": 10, "y": 10})); + assert!(frames(&mut app).iter().all(|(_, m)| !m.shift), "a plain click after it has no Shift"); +} + +#[test] +fn shift_drag_holds_shift_through_the_moves() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": 4, "modifiers": {"shift": true}})); + let f = frames(&mut app); + let first = f.iter().position(|(b, _)| *b).expect("press"); + let last = f.iter().rposition(|(b, _)| *b).expect("release"); + assert!(last > first + 1, "moves between press and release: {f:?}"); + assert!(f[first..=last].iter().all(|(_, m)| m.shift), "Shift held for the whole drag: {f:?}"); +} diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 551f8ad4..3251aed0 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -528,7 +528,7 @@ fn radial_center_stays_draggable_when_its_property_is_selected() { #[test] fn clicking_an_effect_parameter_activates_its_header_and_handles() { let (app, x) = app(); - let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); settle(&mut h); let name = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center(); click(&mut h, name); @@ -656,3 +656,55 @@ fn file_and_text_parameters_have_widgets() { assert_eq!(v, KV::Str("left*".into())); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn effect_control_name_can_be_renamed_from_its_header() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + settle(&mut h); + let name = format!("effectControls.effect.{}.name", x.point_fx); + let point = rect_of(&h, &name).center(); + double_click(&mut h, point); + h.run_steps(3); + assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Speed".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let current_name = + |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); + assert_eq!(current_name(&h), "Speed"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(current_name(&h), "Point Control"); +} + +#[test] +fn effect_rename_escape_keeps_the_name() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + settle(&mut h); + let point = rect_of(&h, &format!("effectControls.effect.{}.name", x.point_fx)).center(); + double_click(&mut h, point); + h.run_steps(3); + assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Speed".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let name = layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); + assert_eq!(name, "Point Control"); + assert!(!ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); +} + +/// Two presses within egui's double-click time (one frame apart). +fn double_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); +} diff --git a/crates/ui-egui/tests/ui_frame_step.rs b/crates/ui-egui/tests/ui_frame_step.rs new file mode 100644 index 00000000..cd3ac329 --- /dev/null +++ b/crates/ui-egui/tests/ui_frame_step.rs @@ -0,0 +1,44 @@ +//! Stepping the current time from the keyboard: Page Down / Page Up and Cmd+Right / Cmd+Left +//! Arrow move one frame, with Shift ten. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use serde_json::json; + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); +} + +#[test] +fn cmd_arrows_step_the_current_frame() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let cid = session.state.active_comp.unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(2); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + }; + assert_eq!(frame(&h), 0); + let cmd = Modifiers::COMMAND; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + key(&mut h, Key::ArrowRight, cmd); + assert_eq!(frame(&h), 1); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_eq!(frame(&h), 2); + key(&mut h, Key::ArrowRight, cmd_shift); + assert_eq!(frame(&h), 12); + key(&mut h, Key::ArrowLeft, cmd); + assert_eq!(frame(&h), 11); + key(&mut h, Key::ArrowLeft, cmd_shift); + assert_eq!(frame(&h), 1); + // The plain arrows still nudge layers, not the time. + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(frame(&h), 1); +} diff --git a/crates/ui-egui/tests/ui_gradient_handles.rs b/crates/ui-egui/tests/ui_gradient_handles.rs new file mode 100644 index 00000000..f5d9d273 --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_handles.rs @@ -0,0 +1,158 @@ +//! #612: a shape layer's Gradient Fill start and end points are dragged in the Composition viewer, +//! as in After Effects. With the gradient (or a property in it) selected, `viewer.gradientPoint.*` +//! handles sit on the points, in the space of the group holding the gradient; a drag sets the +//! point in one undo step and doesn't move the layer. Uses only APIs that existed before the +//! handles, so it runs (and fails) on the code without them. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// The Gradient Fill group in shape contents `g`, through nested groups. +fn gfill(g: &PropGroup) -> Option<&PropGroup> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some(x); + } + if let Some(found) = gfill(x) { + return Some(found); + } + } + None +} + +struct Ids { + layer: u64, + gfill: u64, + start: u64, + end: u64, +} + +/// A 640×360 comp with a rectangle shape layer, a Gradient Fill added inside the rectangle's group +/// (`in_group`) or at the top of its contents, after `prep` runs on the session. +fn setup(in_group: bool, prep: impl FnOnce(&mut Session, u64, u64)) -> (Session, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let group = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().next().unwrap().uid; + let mut p = json!({"layer": l, "kind": "gfill"}); + if in_group { + p["group"] = json!(group); + } + s.execute("layer.addShapeItem", p).unwrap(); + prep(&mut s, l, group); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap(); + let g = gfill(&layer.props).expect("a Gradient Fill"); + let ids = Ids { layer: l, gfill: g.uid, start: g.get("start").unwrap().uid, end: g.get("end").unwrap().uid }; + s.state.selected_layers = vec![LayerId(l)]; + (s, ids) +} + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + h +} + +fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option { + h.state().auto.find(&format!("viewer.gradientPoint.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) +} + +fn point(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> [f64; 2] { + h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_vec2() +} + +/// Screen pixels per comp pixel. +fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 { + let a = comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap(); + let b = comp_to_screen(&h.ctx, [100.0, 0.0]).unwrap(); + (b.x - a.x) / 100.0 +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn near(a: [f64; 2], b: [f64; 2], tol: f64) -> bool { + (a[0] - b[0]).abs() <= tol && (a[1] - b[1]).abs() <= tol +} + +/// With the Gradient Fill selected, its End Point is a handle on the point; dragging it moves the +/// point by the drag (in comp pixels, the layer and group being unscaled), as one undo step, +/// without moving the layer. +#[test] +fn dragging_the_end_point_moves_it_in_one_undo_step() { + let (mut s, ids) = setup(false, |_, _, _| {}); + s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)]; + let mut h = harness(s); + let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.gradientPoint.{}: the Gradient Fill's End Point isn't draggable in the viewer", ids.end)); + assert!(handle(&h, ids.start).is_some(), "and its Start Point"); + let (end, start) = (point(&h, &ids, ids.end), point(&h, &ids, ids.start)); + let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let undo = h.state().session.history.undo.len(); + let z = zoom(&h); + drag(&mut h, at, at + vec2(60.0, 30.0) * z); + let moved = point(&h, &ids, ids.end); + assert!(near(moved, [end[0] + 60.0, end[1] + 30.0], 1.5), "End Point {end:?} moved by the drag: {moved:?}"); + assert_eq!(point(&h, &ids, ids.start), start, "the Start Point stays"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone(); + assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(point(&h, &ids, ids.end), end, "one undo puts it back"); +} + +/// A gradient inside a moved and scaled group: its handles sit where the group draws the points, +/// and a drag converts back into the group's space (half as far at 200%). +#[test] +fn points_follow_the_group_transform() { + let (mut s, ids) = setup(true, |s, l, group| { + let tr = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(group).unwrap().sub("transform").unwrap().clone(); + s.execute("prop.set", json!({"layer": l, "prop": tr.get("scale").unwrap().uid, "value": [200.0, 200.0]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "prop": tr.get("position").unwrap().uid, "value": [40.0, -20.0]})).unwrap(); + }); + s.state.selected_props = vec![(LayerId(ids.layer), ids.start)]; + let mut h = harness(s); + // Layer at the comp's centre (320, 180); group: position (40, -20), scale 200%. + let start = point(&h, &ids, ids.start); + let want = comp_to_screen(&h.ctx, [(320.0 + 40.0 + 2.0 * start[0]) as f32, (180.0 - 20.0 + 2.0 * start[1]) as f32]).unwrap(); + let at = handle(&h, ids.start).expect("a Start Point handle (selecting a property in the gradient shows it)"); + assert!((at - want).length() < 1.5, "the handle is where the group draws the point: {at:?} vs {want:?}"); + let z = zoom(&h); + drag(&mut h, at, at + vec2(40.0, 20.0) * z); + let moved = point(&h, &ids, ids.start); + assert!(near(moved, [start[0] + 20.0, start[1] + 10.0], 1.0), "in group space, half the comp drag: {start:?} → {moved:?}"); +} + +/// No handles unless the gradient (or a property in it) is selected, or with layer controls off. +#[test] +fn handles_show_only_for_a_selected_gradient() { + let (s, ids) = setup(false, |_, _, _| {}); + let h = harness(s); + assert!(handle(&h, ids.end).is_none(), "only the layer is selected"); + let (mut s, ids) = setup(false, |_, _, _| {}); + let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid; + s.state.selected_props = vec![(LayerId(ids.layer), position)]; + let h = harness(s); + assert!(handle(&h, ids.end).is_none(), "another property is selected"); + let (mut s, ids) = setup(false, |_, _, _| {}); + s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)]; + let mut h = harness(s); + assert!(handle(&h, ids.end).is_some()); + h.state_mut().ui.viewer.show_layer_controls = false; + h.run_steps(3); + assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them"); +} diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 4119d79b..dd20e55b 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -72,6 +72,30 @@ fn project_rename_replaces_the_name() { assert!(!h.ctx.egui_wants_keyboard_input(), "Enter releases the keyboard"); } +/// #881: Tab in the Project panel's rename field commits the name like Enter and gives the keyboard +/// back to the panel; it doesn't leave keyboard focus on another widget (the row's label swatch), +/// where Enter would no longer rename and the arrow keys would move between widgets. +#[test] +fn tab_in_the_project_rename_field_commits_and_keeps_enter_renaming() { + let (mut h, comp) = harness(); + let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone(); + let edit = format!("project.item.{comp}.nameEdit"); + let editing = |h: &Harness<'_, EffectcraftApp>| h.state().auto.elements.iter().any(|e| e.id == edit); + let p = center(&h, &format!("project.item.{comp}.name")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + type_text(&mut h, "Hero"); + key(&mut h, Key::Tab); + assert_eq!(name(&h), "Hero", "Tab commits the name"); + assert!(!editing(&h), "Tab closes the rename field"); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "no other widget takes the keyboard"); + key(&mut h, Key::Enter); + assert!(editing(&h), "Enter renames again"); + type_text(&mut h, "Main"); + key(&mut h, Key::Enter); + assert_eq!(name(&h), "Main"); +} + #[test] fn timecode_field_replaces_commits_and_cancels() { let (mut h, _) = harness(); diff --git a/crates/ui-egui/tests/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/ui_layer_panel_zoom.rs new file mode 100644 index 00000000..5fa2b1d9 --- /dev/null +++ b/crates/ui-egui/tests/ui_layer_panel_zoom.rs @@ -0,0 +1,177 @@ +//! #753: the Layer panel zooms and pans (magnification menu, View ▸ Zoom In / Zoom Out while it +//! is focused, the wheel, the Zoom and Hand tools), and the Roto Brush explains itself in the +//! Composition panel (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::state::Tool; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + EffectcraftApp::new(s) +} + +fn box_id(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0 +} + +/// The harness with Box open in the Layer panel (focused). +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.run_steps(3); + let id = box_id(&h); + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().ui.focused, PanelKind::Layer); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: egui::PointerButton, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + press(h, p, egui::PointerButton::Primary, true); + h.step(); + press(h, p, egui::PointerButton::Primary, false); + h.run_steps(2); +} + +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + press(h, from, button, true); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + press(h, to, button, false); + h.run_steps(2); +} + +fn command(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap(); + h.run_steps(3); +} + +/// Shown magnification: the canvas width per layer pixel (Box is 80 px wide). +fn shown(h: &Harness<'_, EffectcraftApp>) -> f32 { + rect(h, "layerPanel.canvas").width() / 80.0 +} + +#[test] +fn zoom_commands_act_on_the_focused_layer_panel() { + let mut h = harness(); + assert!(h.state().auto.find("layerPanel.magnification").is_some(), "the Layer panel has a magnification menu"); + let fit = shown(&h); + let comp_zoom = h.state().ui.viewer.zoom; + command(&mut h, "view.zoomOut"); + let z = h.state().ui.layer_zoom.expect("Zoom Out sets the Layer panel's magnification"); + assert!(z < fit, "{z} < {fit}"); + assert!((shown(&h) - z).abs() < 0.01, "the canvas is drawn at the new magnification"); + assert_eq!(h.state().ui.viewer.zoom, comp_zoom, "the Composition viewer keeps its magnification"); + command(&mut h, "view.zoomIn"); + assert!(h.state().ui.layer_zoom.unwrap() > z); + command(&mut h, "view.fit"); + assert_eq!(h.state().ui.layer_zoom, None); + assert!((shown(&h) - fit).abs() < 0.01); + // With the Composition panel focused, the same commands zoom it instead. + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(2); + command(&mut h, "view.zoomIn"); + assert!(h.state().ui.viewer.zoom.is_some()); + assert_eq!(h.state().ui.layer_zoom, None); +} + +#[test] +fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + // A point a quarter of the way into the layer stays under the pointer. + let at = canvas.min + canvas.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + let after = rect(&h, "layerPanel.canvas"); + assert!(after.width() > canvas.width(), "the wheel zooms in: {} → {}", canvas.width(), after.width()); + let still = after.min + after.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Hand tool drag pans; no paint or Roto stroke is made. + h.state_mut().ui.tool = Tool::Hand; + h.run_steps(2); + let before = rect(&h, "layerPanel.canvas").min; + let c = rect(&h, "layerPanel.area").center(); + drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(60.0, 30.0)); + let moved = rect(&h, "layerPanel.canvas").min - before; + assert!((moved.x - 60.0).abs() < 1.0 && (moved.y - 30.0).abs() < 1.0, "{moved:?}"); + // Middle-drag pans with a Roto Brush tool, painting nothing. + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(2); + let before = rect(&h, "layerPanel.canvas").min; + drag_with(&mut h, egui::PointerButton::Middle, c, c - vec2(40.0, 20.0)); + let moved = rect(&h, "layerPanel.canvas").min - before; + assert!((moved.x + 40.0).abs() < 1.0 && (moved.y + 20.0).abs() < 1.0, "{moved:?}"); + let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(box_id(&h))).unwrap().clone(); + assert!(layer.effects().is_none_or(|fx| fx.groups().count() == 0), "panning adds no Roto Brush effect"); +} + +#[test] +fn zoom_tool_click_zooms_in_the_layer_panel() { + let mut h = harness(); + let fit = shown(&h); + h.state_mut().ui.tool = Tool::Zoom; + h.run_steps(2); + let c = rect(&h, "layerPanel.canvas").center(); + click(&mut h, c); + let z = h.state().ui.layer_zoom.expect("the Zoom tool sets the Layer panel's magnification"); + assert!((z - fit * 2.0).abs() < 0.01, "{fit} → {z}"); + assert_eq!(h.state().ui.viewer.zoom, None, "the Composition viewer keeps Fit"); +} + +#[test] +fn roto_brush_in_the_composition_panel_says_where_it_works() { + let mut h = harness(); + h.state_mut().show_panel(PanelKind::Composition); + h.state_mut().ui.layer_panel = None; + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let c = comp.center(); + click(&mut h, c); + assert!(h.state().ui.status.contains("Layer panel"), "a hint, not silence: {:?}", h.state().ui.status); + // Double-clicking the layer opens it in the Layer panel. + h.run_steps(30); + h.input_mut().events.push(Event::PointerMoved(c)); + h.step(); + // Both clicks inside egui's double-click time; the second release (its own frame, as with a + // real mouse) opens the layer after the press has focused the Composition panel. + press(&mut h, c, egui::PointerButton::Primary, true); + press(&mut h, c, egui::PointerButton::Primary, false); + press(&mut h, c, egui::PointerButton::Primary, true); + h.step(); + press(&mut h, c, egui::PointerButton::Primary, false); + h.run_steps(3); + assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); + assert_eq!(h.state().ui.focused, PanelKind::Layer); +} diff --git a/crates/ui-egui/tests/ui_panel_windows.rs b/crates/ui-egui/tests/ui_panel_windows.rs new file mode 100644 index 00000000..60ae3f9c --- /dev/null +++ b/crates/ui-egui/tests/ui_panel_windows.rs @@ -0,0 +1,87 @@ +//! Multiple monitors (#486): a panel moves out of the main window into a native window of its +//! own (Move Panel to New Window), which can be placed on another screen, and back. +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{PanelKind, PanelWindow}; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, params: serde_json::Value) -> Result { + effectcraft_ui_egui::menus::invoke(app, ctx, "window.panelWindow", params) +} + +#[test] +fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + assert!(app.ui.dock.contains(PanelKind::Project)); + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); + // Undocked first, then given a window of its own. + assert!(!app.ui.dock.contains(PanelKind::Project)); + assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project])); + assert!(app.in_panel_window(PanelKind::Project)); + let window = app.ui.panel_windows.iter().find(|w| w.panel == PanelKind::Project).copied().unwrap(); + assert_eq!(window.rect, PanelWindow::sanitized(window.rect)); + // Asking again changes nothing; no `window` toggles. + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); + assert_eq!(app.ui.panel_windows.len(), 1); + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false})); + // Back in the main window, still floating where it was. + assert!(!app.in_panel_window(PanelKind::Project)); + assert!(app.ui.panel_windows.is_empty()); + assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project])); + // The serialized panel form works too. + assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true})); + // Bad requests are errors and change nothing. + let before = app.ui.panel_windows.clone(); + for params in [json!({}), json!({"panel": "Nope"}), json!({"panel": 7}), json!({"panel": "Project", "window": "yes"}), json!({"panel": {"Viewer": 9}})] { + assert!(invoke(&mut app, &ctx, params.clone()).is_err(), "{params} must be rejected"); + } + assert_eq!(app.ui.panel_windows, before); +} + +#[test] +fn a_panel_in_its_own_window_leaves_the_main_window() { + let mut app = EffectcraftApp::new(Session::default()); + app.ui.start_screen = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + h.run_steps(3); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, json!({"panel": "Project", "window": true})).unwrap(); + h.run_steps(3); + // No in-window floating group is drawn for it (no move strip, no floating area)... + assert!(!h.state().auto.elements.iter().any(|e| e.id.starts_with("panel.float."))); + let area = egui::Id::new(("floating-panel", PanelKind::Project.id())); + assert!(!h.ctx.memory(|m| m.layer_ids().any(|layer| layer.id == area && m.areas().is_visible(&layer)))); + // ...the window draws it (here embedded: the test harness has no native windows). + assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Project")); + // Docking it back into the main window closes the window entry. + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.dockPanel", json!({"panel": "Project", "anchor": "Timeline"})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.panel_windows.is_empty()); + assert!(!h.state().in_panel_window(PanelKind::Project)); +} + +#[test] +fn panel_windows_survive_restart_with_hostile_places_made_safe() { + use effectcraft_engine::config::MemoryConfig; + use std::sync::Arc; + let store = Arc::new(MemoryConfig::default()); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(); + let hostile = [1.0e9, -3.0e12, 1.0e9, 0.5]; + if let Some(w) = app.ui.panel_windows.first_mut() { + w.rect = hostile; + } + // A window for a panel that doesn't float is dropped on load. + app.ui.panel_windows.push(PanelWindow { panel: PanelKind::Timeline, rect: [10.0, 10.0, 300.0, 200.0] }); + eframe::App::on_exit(&mut app); + let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() }); + assert_eq!(restarted.ui.panel_windows, vec![PanelWindow { panel: PanelKind::Project, rect: PanelWindow::sanitized(hostile) }]); + let [x, y, w, h] = PanelWindow::sanitized(hostile); + assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h)); + assert!(PanelWindow::sanitized([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite())); +} diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 5459be8c..930c43eb 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -136,8 +136,11 @@ fn cached_frames_are_counted_at_one_third_resolution() { let mut s = Session::default(); let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + // Each composition keeps its own Resolution (#417). + if let Some(comp) = std::sync::Arc::make_mut(&mut s.project).comp_mut(ItemId(c)) { + comp.resolution = res; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); - h.state_mut().ui.viewer.res = res; settle(&mut h); let series = h.state().shown_series(ItemId(c)); assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}"); diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/ui_settings_info.rs index f110ce0f..9d87eb04 100644 --- a/crates/ui-egui/tests/ui_settings_info.rs +++ b/crates/ui-egui/tests/ui_settings_info.rs @@ -134,3 +134,23 @@ fn gpu_information_names_the_adapter_and_why_the_gpu_is_off() { assert!(h.query_by_label_contains("Window graphics: NVIDIA GeForce RTX 4070").is_some(), "the window's adapter is shown"); assert!(h.query_by_label_contains("GPU compositing is off: GPU compositor unavailable").is_some(), "the reason is shown"); } + +#[test] +fn gpu_information_dialog_stays_in_place() { + let mut session = Session::default(); + session.prefs.appearance.ui_scale = 125; + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1280.0, 720.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"previews"})).unwrap(); + h.run_steps(3); + let button = rect(&h, "settings.button.GPU Information").center(); + click(&mut h, button); + let start = h.get_by_label("GPU Information").rect(); + for _ in 0..20 { + h.step(); + let current = h.get_by_label("GPU Information").rect(); + assert!((current.min - start.min).length() < 1.0, "GPU Information dialog moved"); + } +} diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 4d6777b6..7f4689bf 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -167,6 +167,18 @@ fn nested_comp_markers_show_on_the_precomp_bar() { assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds()); } +/// #637: double-clicking a precomp layer's bar opens its comp, as double-clicking its name does. +#[test] +fn double_clicking_a_precomp_bar_opens_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.run_steps(4); + let l = h.state().session.project.comp(ItemId(main)).unwrap().layers[0].id.0; + let bar = rect(&h, &format!("timeline.layer.{l}.bar")); + click_n(&mut h, bar.center(), 2); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre))); +} + /// Issue #46: a panel dragged by its panel-menu (hamburger) button onto the left edge of another /// group docks there as its own group; dragging the gap between panels resizes them. #[test] diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 1c2c7355..d3be6a44 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -160,6 +160,137 @@ fn type_tool_click_type_edit_and_commit() { assert!(h.state().session.state.text_edit.is_none()); } +/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the +/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: +/// typed characters change no text and Space plays. The Character panel keeps the edit, and a +/// text layer clicked into being and left empty goes away. +#[test] +fn clicking_another_panel_stops_typing_into_text() { + for type_tool in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let title = + s.execute("layer.newText", json!({"text": "Title", "size": 80, "position": [100, 120], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + // (Its row moves down as text layers are added.) + let row = |h: &Harness<'_, EffectcraftApp>| rect(h, &format!("timeline.layer.{solid}.row")).center(); + if type_tool { + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let at = rect(&h, "viewer.comp").center(); + click(&mut h, at, Modifiers::NONE); + assert_eq!(layers(&h), 3, "the Type tool started a text layer"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert_eq!(layers(&h), 2, "left empty, it is removed"); + click(&mut h, at, Modifiers::NONE); + } else { + h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); + h.run_steps(2); + } + type_text(&mut h, "Hello"); + // The Character panel formats the selection and keeps the edit: typing carries on. + key(&mut h, Key::ArrowLeft, Modifiers::SHIFT); + let range = edited(&h); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + assert_eq!(edited(&h), range, "type_tool {type_tool}: Character keeps the edit and its selection"); + key(&mut h, Key::End, Modifiers::NONE); + type_text(&mut h, "!"); + let (lid, text, ..) = edited(&h); + assert!(text.ends_with("Hello!"), "typing goes into the text: {text:?}"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "type_tool {type_tool}: clicking the Timeline ends the edit"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(solid)]); + assert!(!h.ctx.memory(|m| m.focused().is_some()), "the keyboard is the app's again"); + type_text(&mut h, "abc"); + assert!(!h.state().playback.playing); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(" ".into())); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, text, "type_tool {type_tool}: the text is unchanged"); + assert!(h.state().playback.playing, "type_tool {type_tool}: Space plays"); + assert_eq!(type_tool, matches!(h.state().ui.tool, effectcraft_ui_egui::state::Tool::Type), "the tool is kept"); + } +} + +/// #831: opening a formatting panel's tab behind Effects & Presets in its group (Properties, in +/// Undocked Panels) keeps the edit and its selected characters: the press on the tab used to +/// focus the shown panel and end the edit, after which Faux Bold styled the whole layer. +#[test] +fn opening_a_formatting_tab_keeps_the_text_selection() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = + s.execute("layer.newText", json!({"text": "Hello world", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + // Effects & Presets shown, Properties, Character and Paragraph behind it. + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Undocked Panels"})).unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": lid})).unwrap(); + h.state_mut().session.execute("text.setSelection", json!({"anchor": 0, "caret": 5})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find("properties.text.style").is_none(), "Properties is behind Effects & Presets"); + let tab = rect(&h, "panel.tab.Properties").center(); + click(&mut h, tab, Modifiers::NONE); + assert!(h.state().auto.find("properties.text.style").is_some(), "the tab opened Properties"); + assert_eq!(edited(&h), (lid, "Hello world".into(), 0, 5), "the edit and its selection are kept"); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert!(doc.style_at(1).faux_bold && !doc.style_at(7).faux_bold, "only \"Hello\" is bold"); +} + +/// #831: a click away and keys in one frame's input. The text gets the keys typed before the +/// click and none after it; those later keys don't run as shortcuts either (Delete would remove a +/// layer). From the next frame the keys are the app's: Space plays. +#[test] +fn keys_after_a_click_away_in_the_same_frame_skip_the_text() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Hello", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap(); + h.run_steps(2); + let row = rect(&h, &format!("timeline.layer.{solid}.row")).center(); + let key = |key: Key, pressed: bool| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ + Event::Text("A".into()), + Event::PointerMoved(row), + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Text("x".into()), + key(Key::Space, true), + Event::Text(" ".into()), + key(Key::Space, false), + key(Key::Delete, true), + key(Key::Delete, false), + ]); + h.run_steps(3); + let comp = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA", "only the key before the click"); + assert!(h.state().session.state.text_edit.is_none(), "the click ended the edit"); + assert_eq!((comp(&h), h.state().playback.playing), (2, false), "the keys after it ran no shortcut"); + h.input_mut().events.extend([key(Key::Space, true), Event::Text(" ".into()), key(Key::Space, false)]); + h.run_steps(2); + assert!(h.state().playback.playing, "Space plays"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA"); +} + #[test] fn switching_from_type_to_selection_commits_and_moves_text_layer() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app()); @@ -277,3 +408,59 @@ fn type_tool_drag_makes_paragraph_text_with_box_handles() { assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size); assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing"); } + +/// #866: Layer ▸ New ▸ Text (menu or its shortcut) puts an insertion point in the comp, as After +/// Effects does, so typed keys reach Source Text (and the layer name follows) instead of running +/// shortcuts while the layer keeps the placeholder "Text". +#[test] +fn layer_new_text_menu_and_shortcut_start_typing_into_source_text() { + for via_shortcut in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + if via_shortcut { + key(&mut h, Key::T, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT); + } else { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newText", json!({})).unwrap(); + h.run_steps(2); + } + let (lid, text, _, _) = edited(&h); + assert_eq!(text, "", "no placeholder to type over (shortcut: {via_shortcut})"); + type_text(&mut h, "My First Project"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "Escape commits"); + let s = &h.state().session; + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "My First Project", "typed text is the Source Text"); + assert_eq!(s.active_comp().unwrap().layer(LayerId(lid)).unwrap().name, "My First Project", "the name follows the text"); + } +} + +/// Double-clicking a text layer in the Timeline, on its name or on its bar, edits its text in the +/// Composition viewer with the whole text selected, as in After Effects (Enter renames). +#[test] +fn timeline_double_click_on_a_text_layer_edits_its_text() { + for target in ["row", "bar"] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "alpha beta"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center(); + click(&mut h, p, Modifiers::NONE); + click(&mut h, p, Modifiers::NONE); + let (layer, text, anchor, caret) = edited(&h); + assert_eq!(layer, lid, "{target}"); + assert_eq!((anchor.min(caret), anchor.max(caret)), (0, 10), "everything selected ({target})"); + assert!(h.state().auto.find(&format!("timeline.layer.{lid}.nameEdit")).is_none(), "no rename field ({target})"); + assert_eq!(text, "alpha beta"); + type_text(&mut h, "gamma"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "Escape commits ({target})"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})"); + } +} diff --git a/crates/ui-egui/tests/ui_theme_favorites.rs b/crates/ui-egui/tests/ui_theme_favorites.rs new file mode 100644 index 00000000..1b700f51 --- /dev/null +++ b/crates/ui-egui/tests/ui_theme_favorites.rs @@ -0,0 +1,68 @@ +use effectcraft_engine::{Session, config::MemoryConfig, prefs::Prefs}; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use std::sync::Arc; + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + h.event(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} +#[test] +fn favorites_validate_and_survive_settings_reload() { + let loaded = Prefs::from_json(r#"{"appearance":{"favoriteThemes":["dark","unknown","dark","light"]}}"#); + assert_eq!(loaded.appearance.favorite_themes, vec!["dark", "light"]); + let mut p = Prefs::default(); + for bad in [json!(["dark", "dark"]), json!(["unknown"]), json!([3]), json!("dark")] { + assert!(p.set("appearance.favoriteThemes", bad).is_err()); + assert!(p.appearance.favorite_themes.is_empty()); + } + p.set("appearance.favoriteThemes", json!(["light", "darker"])).unwrap(); + let loaded = Prefs::from_json(&p.to_json()); + assert_eq!(loaded.appearance.favorite_themes, p.appearance.favorite_themes); +} +#[test] +fn star_controls_save_and_favorite_menu_selects_the_family() { + let store = Arc::new(MemoryConfig::default()); + let mut session = Session { config: Some(store.clone()), ..Default::default() }; + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"appearance"})).unwrap(); + h.run_steps(3); + let p = at(&h, "settings.appearance.lightTheme.light"); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + let p = at(&h, "settings.appearance.favoriteThemes.light"); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["light"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + std::fs::create_dir_all(&path).unwrap(); + h.render().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap(); + } + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "dark"); + let p = at(&h, "settings.ok"); + click(&mut h, p, PointerButton::Primary); + let mut saved = Session { config: Some(store), ..Default::default() }; + saved.load_settings(); + assert_eq!(saved.prefs.appearance.favorite_themes, vec!["light"]); + h.state_mut().set_pref("appearance.favoriteThemes", json!(["darker", "light"])).unwrap(); + let p = at(&h, "header.appearance"); + click(&mut h, p, PointerButton::Secondary); + let p = at(&h, "header.favoriteTheme.light"); + assert!(p.y < at(&h, "header.favoriteTheme.darker").y); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "light"); + assert_eq!(h.state().session.prefs.appearance.dark_theme, "dark"); +} diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs index 20f861d4..ea0798d9 100644 --- a/crates/ui-egui/tests/ui_timeline_switches.rs +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -100,6 +100,37 @@ fn dragging_over_layer_switches_sets_them_all() { assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched"); } +/// Alt-click on an A/V feature sets the layer apart (the other layers hidden / locked), and the +/// next Alt-click brings the others back; the Switches column is not affected by Alt. +#[test] +fn alt_click_on_av_feature_switches_the_other_layers() { + let (mut h, ids) = harness(); + let alt_click = |h: &mut Harness<'_, EffectcraftApp>, id: String| { + let p = rect(h, &id).center(); + let modifiers = Modifiers::ALT; + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let steps = h.state().session.history.undo.len(); + alt_click(&mut h, format!("timeline.layer.{}.video", ids[1])); + assert_eq!(states(&h, &ids, video), [false, true, false, false]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + alt_click(&mut h, format!("timeline.layer.{}.video", ids[1])); + assert_eq!(states(&h, &ids, video), [true; 4]); + alt_click(&mut h, format!("timeline.layer.{}.lock", ids[2])); + assert_eq!(states(&h, &ids, |l| l.switches.locked), [true, true, false, true]); + alt_click(&mut h, format!("timeline.layer.{}.solo", ids[0])); + assert_eq!(states(&h, &ids, |l| l.switches.solo), [true, false, false, false]); + alt_click(&mut h, format!("timeline.layer.{}.switch.motionBlur", ids[0])); + assert_eq!(states(&h, &ids, motion_blur), [true, false, false, false]); +} + /// #284: pressing a stopwatch and dragging over the stopwatches below gives every property the /// state the first one got (animated or not), in one undo step; Alt+click still adds an /// expression and a click toggles one property. diff --git a/crates/ui-egui/tests/ui_toolbar_gradient.rs b/crates/ui-egui/tests/ui_toolbar_gradient.rs new file mode 100644 index 00000000..b5682ffa --- /dev/null +++ b/crates/ui-egui/tests/ui_toolbar_gradient.rs @@ -0,0 +1,140 @@ +//! The toolbar's Fill / Stroke swatch with Linear or Radial Gradient (#612, reported on Discord): +//! it opens the Gradient Editor on the selected shape layer's Gradient Fill / Gradient Stroke +//! Colors, as in After Effects, instead of Fill / Stroke Options. The words still open the +//! options, a Solid Color swatch the colour picker, Alt-click still steps through the kinds, and +//! with no such gradient selected the swatch opens the options as before. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::state::Tool; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// The Colors uid of the first `paint` ("gfill" / "gstroke") group in `g`, through nested groups. +fn colors(g: &PropGroup, paint: &str) -> Option { + g.groups().find_map(|x| if x.match_id == paint { x.get("colors").map(|p| p.uid) } else { colors(x, paint) }) +} + +/// A rectangle shape layer (selected unless `select` is false) with `items` added to its +/// contents, and the toolbar set with `tool` (shape.toolOptions). +fn setup(items: &[&str], tool: serde_json::Value, select: bool) -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + for kind in items { + s.execute("layer.addShapeItem", json!({"layer": l, "kind": kind})).unwrap(); + } + s.execute("shape.toolOptions", tool.clone()).unwrap(); + // The toolbar shows a selected shape layer's own Fill / Stroke kind (#732), so the layer gets + // the kind too. + if select && let Some(o) = tool.as_object() { + let mut p = o.clone(); + p.insert("layers".into(), json!([l])); + s.execute("shape.fillStroke", serde_json::Value::Object(p)).unwrap(); + } + s.state.selected_layers = if select { vec![LayerId(l)] } else { vec![] }; + s.state.selected_props.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + // With no shape layer selected, Fill / Stroke show while a shape tool is active. + if !select { + h.state_mut().ui.tool = Tool::Rectangle; + } + h.run_steps(4); + (h, l) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, modifiers: Modifiers) { + let p = center(h, id); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +fn layer_colors(h: &Harness<'_, EffectcraftApp>, l: u64, paint: &str) -> u64 { + colors(&h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props, paint).expect("the gradient") +} + +/// The options popup is open (its kind buttons are in the automation tree). +fn options_open(h: &Harness<'_, EffectcraftApp>, key: &str) -> bool { + h.state().auto.find(&format!("header.{key}Options.solid")).is_some() +} + +/// The reported bug: a Linear Gradient Fill swatch opened Fill Options. It opens the Gradient +/// Editor on the selected layer's Gradient Fill Colors, and OK sets them. +#[test] +fn a_gradient_fill_swatch_opens_the_gradient_editor() { + let (mut h, l) = setup(&["gfill"], json!({"fillType": "linear"}), true); + let uid = layer_colors(&h, l, "gfill"); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the swatch opens the Gradient Editor, not Fill Options"); + assert!(!options_open(&h, "fill"), "and not the options"); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the layer's Gradient Fill Colors"); + // Edit and save it: the property really changes. + let bar = h.state().auto.find("dialog.gradient.addColor").unwrap().rect; + let at = pos2(bar[0] + bar[2] * 0.5, bar[1] + bar[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + click(&mut h, "dialog.gradient.ok", Modifiers::NONE); + let v = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().value.clone(); + let Value::Gradient(g) = v else { panic!("not a gradient: {v:?}") }; + assert_eq!(g.colors.len(), 3, "the added stop was saved: {g:?}"); +} + +/// The Stroke swatch with Radial Gradient edits the Gradient Stroke's Colors (not the fill's). +#[test] +fn a_gradient_stroke_swatch_edits_the_gradient_stroke() { + let (mut h, l) = setup(&["gfill", "gstroke"], json!({"strokeType": "radial"}), true); + let uid = layer_colors(&h, l, "gstroke"); + click(&mut h, "header.stroke", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the Gradient Stroke's Colors"); +} + +/// With no shape layer selected the swatch opens the options (the kind new shapes get) as before. +/// (A selected shape layer shows its own kind, #732, so a gradient kind always has its gradient.) +#[test] +fn without_a_selected_gradient_the_swatch_opens_the_options() { + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), false); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, None, "no editor"); + assert!(options_open(&h, "fill"), "Fill Options"); +} + +/// The rest of the toolbar is unchanged: the words open the options, a Solid Color swatch opens the +/// colour picker, and Alt-click steps to the next kind. +#[test] +fn words_solid_swatches_and_alt_click_are_unchanged() { + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fillOptions", Modifiers::NONE); + assert!(options_open(&h, "fill"), "the word opens Fill Options"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "solid"}), true); + click(&mut h, "header.fill", Modifiers::NONE); + assert!(h.query_by_label("Color field").is_some(), "a Solid Color swatch opens the colour picker"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fill", Modifiers::ALT); + assert_eq!(h.state().dialog, None, "Alt-click doesn't open the editor"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), "radial", "it steps Linear → Radial"); +} diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs new file mode 100644 index 00000000..9b48b953 --- /dev/null +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -0,0 +1,63 @@ +//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a +//! notice on the layer in the Composition viewer, instead of a black picture with no reason. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::render::FootageSource; +use effectcraft_render::Image; +use effectcraft_time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; + +/// A source that decodes nothing and, for `broken` movies, says why. +struct Stub(bool); + +impl FootageSource for Stub { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, _: &Footage) -> Option { + self.0.then(|| WHY.to_string()) + } +} + +fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { + let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let footage = Footage { + kind: FootageKind::Video, + path: "clip.mp4".into(), + width: 64, + height: 36, + has_video: true, + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + (h, clip) +} + +#[test] +fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { + let (h, clip) = harness(true); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(WHY), "{notice:?}"); +} + +#[test] +fn readable_footage_has_no_notice() { + let (h, clip) = harness(false); + assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); + assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index b7c732ce..07036ca8 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -162,6 +162,56 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() { assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer"); } +/// Each composition keeps its Resolution, saved with the project as in After Effects: chosen for +/// comp A in the viewer's popup, it is still A's after showing comp B, costs no undo step, keeps +/// A's cached frames and is read back from the saved file (#417). +#[test] +fn each_comp_keeps_its_viewer_resolution() { + use effectcraft_ui_egui::state::Resolution; + let mut h = harness(); + let a = h.state().session.active_comp_id().unwrap(); + let content = |h: &Harness<'_, EffectcraftApp>, c| { + let s = h.state(); + s.frames.content_of(&s.session.project, s.session.revision, c) + }; + let (before, steps) = (content(&h, a), h.state().session.history.undo.len()); + for id in ["viewer.resolution", "viewer.resolutionItem.1"] { + let at = rect(&h, id).center(); + click(&mut h, at); + } + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert_eq!(h.state().session.project.comp(a).unwrap().resolution, Resolution::Half); + assert_eq!(h.state().session.history.undo.len(), steps, "no undo step"); + assert_eq!(content(&h, a), before, "the comp's cached frames stay valid"); + // Comp B starts at Auto and gets its own (here through the control channel's `ui.set`). + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap(); + h.run_steps(3); + let b = h.state().session.active_comp_id().unwrap(); + assert_ne!(a, b); + assert_eq!(h.state().viewer_res(), Resolution::Auto); + let (req, _reply) = effectcraft_ui_egui::control::ControlRequest::new("ui.set", json!({"viewer": {"res": "quarter"}})); + let ctx = h.ctx.clone(); + let _ = effectcraft_ui_egui::control::handle(h.state_mut(), &ctx, &req); + assert_eq!(h.state().viewer_res(), Resolution::Quarter); + let ctx = h.ctx.clone(); + assert_eq!(effectcraft_ui_egui::control::inspect(h.state(), &ctx)["ui"]["viewer"]["res"], json!("Quarter")); + // Back to comp A: its resolution again. + h.state_mut().session.open_comp(a); + h.run_steps(3); + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert!(h.state().viewer_scale(1.0, 1.0) == 0.5); + // Saved and opened again. + let path = std::env::temp_dir().join(format!("ec-viewer-res-{}.ecproj", std::process::id())); + h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::default(); + s.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + s.open_comp(b); + let app = EffectcraftApp::new(s); + assert_eq!(app.viewer_res(), Resolution::Quarter); + assert_eq!(app.session.project.comp(a).unwrap().resolution, Resolution::Half); +} + #[test] fn bottom_bar_in_after_effects_order() { let h = harness(); @@ -1878,7 +1928,7 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); // (egui's font atlas needs 1024.) h.input_mut().max_texture_side = Some(1024); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]); for _ in 0..400 { h.step(); @@ -1887,7 +1937,7 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { } std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); + assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().viewer_res()); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } @@ -1974,3 +2024,67 @@ fn vertex_tools_edit_motion_path_keyframes() { click(&mut h, quarter); assert_eq!(keys(&h).len(), 3); } + +/// #882: at 100 % (and 200 %) on a scaled display the frame's corner sits on a physical pixel, so +/// every comp pixel covers whole screen pixels instead of being blended with its neighbour. +#[test] +fn frame_sits_on_the_physical_pixel_grid() { + for ppp in [1.0f32, 1.25, 1.5] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "width": 301, "height": 201, "duration": 1})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + for (mag, pan) in [(1.0f32, [0.3f32, 0.37]), (2.0, [-0.45, 0.2])] { + h.state_mut().ui.viewer.zoom = Some(mag / ppp); + h.state_mut().ui.viewer.pan = pan; + h.run_steps(2); + let c = rect(&h, "viewer.comp"); + for v in [c.min.x * ppp, c.min.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}, {mag}×: corner at {v} physical px"); + } + assert!((c.width() * ppp - 301.0 * mag).abs() < 1e-2, "ppp {ppp}, {mag}×: {} physical px wide", c.width() * ppp); + } + } +} + +/// Magnification ▸ Fit up to 100% keeps fitting as the panel changes size (Maximize Panel), like +/// Fit, and never magnifies past 100%. +#[test] +fn fit_up_to_100_follows_the_maximized_panel() { + use effectcraft_ui_egui::dock::PanelKind; + let pick = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect(h, "viewer.magnification").center(); + click(h, at); + let at = rect(h, "viewer.magnification.1").center(); + click(h, at); + assert_eq!(h.state().ui.viewer.zoom, None, "a fit mode, not a fixed magnification"); + assert!(h.state().ui.viewer.fit_up_to_100); + h.run_steps(2); + }; + // A comp larger than the panel: it fills the docked panel, then the maximized one. + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "width": 3840, "height": 2160, "duration": 4})).unwrap(); + EffectcraftApp::new(s) + }); + h.run_steps(3); + pick(&mut h); + let docked = rect(&h, "viewer.comp").width(); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + let area = rect(&h, "viewer.area"); + let maximized = rect(&h, "viewer.comp"); + assert!(maximized.width() > docked + 100.0, "{docked} -> {}", maximized.width()); + assert!(area.contains_rect(maximized), "{maximized:?} in {area:?}"); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert_eq!(rect(&h, "viewer.comp").width(), docked, "and back when the panel is restored"); + // A comp smaller than the panel stops at 100%. + let mut h = harness(); + pick(&mut h); + let ppp = h.ctx.pixels_per_point(); + assert!(rect(&h, "viewer.comp").width() <= 640.0 / ppp + 0.01); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01); +} diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index fa97b571..d706a382 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -170,7 +170,15 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { let mut frames2 = Frames::default(); frames2.set_remote(Some(worker2.clone())); assert!(frames2.remote_disk()); - let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + let bar = loop { + let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + if !bar.is_empty() { + break bar; + } + assert!(std::time::Instant::now() < until, "disk-cache indicator did not finish hashing"); + std::thread::sleep(std::time::Duration::from_millis(1)); + }; assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame"); frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts); frames2.dispatch_remote(); diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 7b1ca768..697ff2a2 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -177,7 +177,6 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap(); h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap(); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap(); h.run_steps(3); @@ -188,12 +187,12 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.run_steps(2); assert!(h.state().ui.viewer.property_interacting); assert!(h.state().viewer_scale(1.0, 1.0) < 1.0); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE); h.run_steps(2); assert!(!h.state().ui.viewer.property_interacting); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; + h.state_mut().session.execute("view.res.auto", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } @@ -430,3 +429,82 @@ fn timeline_selected_effect_row_highlights() { assert!(h.state().session.state.selected_props.contains(&(id, fx)), "the click selects the effect"); assert!(highlighted(&h), "the selected effect's row is highlighted"); } + +/// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it, +/// so the context menu's commands act on every selected layer; right-clicking an unselected +/// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the +/// menu acted on the right-clicked layer only (#878). +#[test] +fn timeline_context_menu_acts_on_the_whole_selection() { + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) + }; + let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + // Outline row: Delete removes both selected layers. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[1]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[1]]); + h.get_by_label("Delete").click(); + h.run_steps(3); + let left: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + assert_eq!(left, vec![ids[2], ids[3]]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // Layer bar: same, with Duplicate. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0, ids[3].0]})).unwrap(); + h.run_steps(3); + let p = bar(&h, ids[3]); + right_click(&mut h, p); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // An unselected row: right-clicking selects only it. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[3]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]); +} + +/// The Project panel's Move to Project Root acts on every selected item when the right-clicked +/// item is part of the selection, as Delete and Label do. +#[test] +fn project_context_move_to_root_acts_on_the_whole_selection() { + let mut h = harness(); + let solids: Vec = { + let p = &h.state().session.project; + p.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0).collect() + }; + assert!(solids.len() >= 2); + let folder = h.state_mut().session.execute("project.newFolder", json!({"name":"Bin"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": solids, "folder": folder})).unwrap(); + h.state_mut().ui.project_open_folders.insert(folder); + h.state_mut().session.execute("project.select", json!({"items": [solids[0], solids[1]]})).unwrap(); + h.run_steps(3); + let p = rect(&h, &format!("project.item.{}.name", solids[1])).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_label("Move to Project Root").click(); + h.run_steps(3); + let project = &h.state().session.project; + for id in &solids[..2] { + assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); + } +} diff --git a/docs/agents.md b/docs/agents.md index c4c41b0a..01738edd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -123,7 +123,8 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `render_preview` | Render a PNG without writing a file (`comp`, `time`, `max_side`, `transparent`). | Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are -referenced by id or name, and default to the active comp. Property paths come from `get_layer`, for +referenced by id or name, and default to the active comp. An id may also be sent as a string (`"98"`): +an all-digit string that matches no name is taken as the id. Property paths come from `get_layer`, for example `transform/position`, `transform/opacity`, `effects/#1/blurriness` or `@57` (by uid). Times are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or stretched. @@ -144,7 +145,7 @@ carries outside-range warnings; the existing objects from `keys.set` and `prop.g also report both bases and warnings. `prop.get.time` is comp seconds. Separated Position `prop.set` retains per-axis replies; for these commands, `path` or `prop` identifies a property, not a group. -Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds. +Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds; `inDelta` / `outDelta` trim each layer from its own in/out point. For an unstretched layer starting at 10.5 s in a 30 fps comp, `prop.addKey {layer: "A", path: "transform/opacity", time: 15.3, timeBase: "comp", value: 0}` writes a key at comp 15.3 s (layer 4.8 s), matching @@ -219,6 +220,10 @@ build real projects through these interfaces; they are worked examples of everyt * **Nested comps**: `comp.settings {preserveFrameRate, preserveResolution}` (Composition Settings ▸ Advanced); `comp.info` / `get_comp` report both. A precomp layer shows nothing outside its nested comp's span. +* **Viewer Resolution** belongs to each comp and is saved with the project, as in After Effects: + `view.res.full|half|third|quarter|auto {comp?}` or `view.res.custom {factor: 1..40, comp?}` + (headless too; no undo step, the project becomes modified). `comp.info` reports `resolution` + and `resolutionFactor` (`null` for Auto). * **Several footage items at once**: `file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}` is one undo step (select the items first: `project.select`). @@ -498,6 +503,12 @@ one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` sa options a font draws with its own glyphs. A tool with its own font library can hand it over without installing it: folders listed in `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders. +On Linux and the BSDs the system font folders are `/usr/share/fonts`, `/usr/local/share/fonts`, +`~/.local/share/fonts`, `~/.fonts`, `$XDG_DATA_HOME/fonts`, `fonts` in each `$XDG_DATA_DIRS` folder, +every `` the fontconfig configuration names (`/etc/fonts/fonts.conf` with its includes, +`~/.config/fontconfig/fonts.conf`) and, in the Flatpak, the host's fonts under `/run/host`. The +Flatpak can't see other folders; grant one with +`flatpak override --user --filesystem=:ro --env=EFFECTCRAFT_FONT_DIRS= ai.storyteller.effectcraft`. In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a diff --git a/docs/architecture.md b/docs/architecture.md index 9e6fc0e7..72d8b734 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,10 @@ XML). Saving goes through `Project::to_file_json`, which checks that the text re anything is written (JSON can't hold NaN or infinity), and files are written atomically. Opening a file that a newer EffectCraft saved shows a warning: fields this version doesn't know are dropped if it saves the project. Save, Save As, Save a Copy and Increment and Save keep the -file's format (`.ecprojx` stays XML). +file's format (`.ecprojx` stays XML). Next to `savedBy` the file records the active comp and the +open comp tabs (`activeComp`, `openComps`), which opening restores; they are editor state, not +part of `Project`, so older files (and ids that no longer name a comp) open on the first comp and +older versions ignore them. `schema` changes when a saved value changes meaning; a project from an older schema is converted when it is opened (`effectcraft_engine::upgrade_effects`, which also refreshes effect instances @@ -191,7 +194,10 @@ are dropped; running jobs finish into the cache. When the budget (Settings ▸ M full the least recently shown frames go first. The timeline's green bar counts cached frames of the current content and viewer settings. Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above -50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages +50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). The Resolution is the comp's +(`Comp::resolution`, saved with the project): setting it is no undo step (undo, redo and history +jumps carry the current values over) and, like the Shy switches, it is outside +`Comp::same_pixels`, so cached frames stay valid. Below 100 % the viewer averages instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one texel per screen pixel. @@ -388,6 +394,9 @@ their on-screen scale, as text is vector in After Effects; on shape layers and v switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points. +Dragging a layer bar's in or out edge with several layers selected trims every selected layer by +the same amount from its own in/out point (`layer.timing {inDelta | outDelta}`), clamped to the +comp and to one frame of layer, as one undo step. **Nested comps** show nothing before they start or after they end (`Comp::covers`), even when the precomp layer is trimmed or remapped past them. A comp with **Preserve frame rate when nested @@ -540,10 +549,24 @@ text, disables the expression and shows a warning, like After Effects. `sampleIm sampled layer through the evaluating renderer's footage source (`EvalCtx::footage`), cached per thread and frame; `footage(name)` reads data footage (JSON, CSV, TSV imported with File ▸ Import, text kept in the project) through `sourceData`, `sourceText` and `dataValue`. `expr.errors` -lists failing expressions for the viewer's error bar. Each thread keeps one boa context; boa +lists failing expressions on demand. The viewer's error bar uses the same checks against an +immutable project snapshot, after 150 ms without editing, pointer interaction or playback. +Native builds run these automatic checks on one persistent worker; layout and paint only read +completed results. Requests are coalesced per comp, cancelled between properties when obsolete, +and bounded to eight comp entries. Results must match the current project, revision and time. +Disabling Show Expression Error Banner cancels automatic checks. Non-solo layers still receive +diagnostics, including disabled expressions with syntax errors. The single-threaded web build +shares the cache and debounce but evaluates one scan on the UI thread after interaction stops. +Each thread keeps one boa context; boa leaves values on its stack whenever an exception unwinds through call frames (every pending host request does), so a run that hits the stack limit gets a fresh context and is retried once. +Snapping memoizes expression values while collecting one immutable set of targets. Bounds, +vertices and transforms share those values; the cache is discarded before the next query, so +time changes and edits always use current geometry. Native timeline disk-cache indicators also +request missing content hashes in the background, coalescing edits per comp. Their blue bars +appear after the key is ready instead of serializing composition content during painting. + ## 5a. Scripting `effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d76716fd..f5487981 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -134,7 +134,10 @@ Viewer state that agents drive headless too: `view.snapping`, `view.snappingOpti `header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed Compositions and Text Layers), `view.channel {channel, colorized?}`, `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, -`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, +`view.fastPreviewMode {mode}`, `view.res.full|half|third|quarter|auto {comp?}` and +`view.res.custom {factor, comp?}` (the comp's Resolution, saved with the project but no undo +step; `ui.set {viewer: {res}}` sets the active comp's, and `ui.inspect` reports it as +`ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), `shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), `shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options), diff --git a/docs/effects.md b/docs/effects.md index 0fac41ed..7b8f479e 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 306 effects with 3014 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 306 effects with 3017 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 07773cda..67b13203 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -47,7 +47,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't | | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | -| WebP | — | yes | — | beyond After Effects | +| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output | | DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | diff --git a/docs/gaps.md b/docs/gaps.md index d22e9871..e621ee87 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -76,7 +76,7 @@ are grouped by area; the issue tracker has the rest. | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | -| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | +| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | | 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | @@ -141,7 +141,15 @@ The most important missing piece: it turns every other estimate here into a meas Location keeps its selection and fields; a layer deleted while editing is reported; and `prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe` and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round - trip, and stops staying in order, and selected, while they move. + trip, and stops staying in order, and selected, while they move. The `ui_gradient_handles` + tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in + the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group + converts to the group's space, and the handles show only for a selected gradient. +- Regression evidence for the toolbar's gradient swatches (#612, reported on Discord): the + `ui_toolbar_gradient` input tests, checked headlessly on Windows, click a Linear Fill swatch and + a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill + / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, + and the words, Solid swatches and Alt-click are unchanged. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template @@ -211,7 +219,12 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie gate is a P0. - Fuzz the inputs we don't control: project files, imported media and documents, expressions, scripts, control-channel and MCP requests. Every crash found becomes a regression test - (AGENTS.md "Never crash"). + (AGENTS.md "Never crash"). Project files are fuzzed (#779): a seeded, dependency-free mutation + fuzzer runs in `cargo test -p effectcraft-engine` (`fuzz_file_open_never_crashes`, `.ecproj` + JSON and `.ecprojx` XML through File ▸ Open, the first queries and a thumbnail render; longer + runs via `EFFECTCRAFT_FUZZ_ITERS` / `EFFECTCRAFT_FUZZ_SECS`). It found one crash, deeply nested + XML overflowing the stack, now refused. Imported media and documents, expressions, scripts and + control-channel / MCP requests are still to do. - Run the gate on every pull request in CI (gap 4): today no workflow runs the tests on PRs. - Fix the open crash reports first (gap 3). - Done when: the gate is green on stable, runs on every pull request, and the fuzzers run diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 922f75e6..145e5b24 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ monitors are missing, and two GPU start-up failures are open. | Video output devices | Mercury Transmit to AJA, Blackmagic, second display | second preview window only (Video Preview) | same | same | — | No SDI/HDMI device output | | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | -| Multiple monitors | panels and windows across screens | floating panels within one window only | same | same (#486) | — | gap 20 | +| Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | | Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late on Linux (#554) and after a start offset (#504) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ff70ca62..61dfe5f1 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first measurement of every catalog against the After Effects 2026 26.5 language set) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Ukrainian panel catalog recounted after it caught up with the Chinese ones) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -22,7 +22,7 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea **618** fixed entries. The generated Effect menu (effect names) is deliberately left in English. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese, Simplified and Traditional - Chinese and Ukrainian. + Chinese, Ukrainian and Czech. - **Not translated anywhere yet:** effect names and their ≈ 1,650 parameter names (`crates/effects`), engine-generated text (property names, errors, undo labels), the Home screen's unwrapped literals (#574), Learn tutorials and documentation. @@ -60,12 +60,13 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | No Hangul fallback probe: boxes on macOS (#599) | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (2): +Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,081 (≈ 28%): menus 618/618, panels 463 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,015 (≈ 26%): menus 618/618, panels 397 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,113 (≈ 29%): menus 618/618, panels 495/496 | partial | 6–9 h + review | +| Czech | `cs` | 1,114 catalog entries (≈ 29%): menus 618/618, panels 496/496 (current Chinese catalog); added after the measurement above | All existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | After Effects also ships Italian and Russian, which EffectCraft does not (8–12 h each). @@ -87,3 +88,4 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/preferences.md b/docs/preferences.md index 42acd931..3b92782c 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -57,6 +57,7 @@ Commands (CLI, MCP, control channel): - `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). - `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance - `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.favoriteThemes`: a list of unique theme ids (`dark`, `darker`, `light`), empty by default. Hover a theme row to star it; saved favorites keep an accent-colored star. Right-click the appearance button for Appearance Settings and favorites grouped light first, then dark. Selecting a favorite fixes its appearance family and preserves the opposite family choice; left-click still cycles modes. Settings OK saves favorites and Cancel restores them. Unknown or duplicate ids are rejected by `prefs.set` and removed when loading old settings. - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from @@ -95,7 +96,9 @@ Commands (CLI, MCP, control channel): how much of a position motion path the viewer draws (all, none, N seconds or N keyframes around the current time) - `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp -- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear +- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = averaged + (bilinear, mipmapped) when the frame is shown below 100 %; magnified frames show their pixels as + blocks in both modes, and the frame sits on the physical pixel grid, so 100 % is exact - `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management - `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour, Effect Controls / Properties the layer's @@ -224,3 +227,17 @@ checkpoint after a client restart. Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`, `file.clearRecent`, `file.revert`, `file.incrementAndSave`. + +## Logs + +The desktop app writes its log records to standard error and to `Logs/effectcraft.log` in the +config directory. Each launch moves the previous log to `effectcraft.1.log` (and that one to +`effectcraft.2.log`), so the log of a run that crashed survives the next start; a log stops +growing at 16 MiB. A launch from the Dock, the Start menu or a launcher has no terminal, so +attach these files to a bug report. Help ▸ Reveal Logging File shows this run's log (or Help ▸ +Enable Logging's `EffectCraft Log.txt` while that is on, which keeps every EffectCraft record +across launches). + +By default the log has `info` records from EffectCraft and warnings and errors from everything +else. `RUST_LOG` replaces that with env_logger-style directives: `debug`, +`warn,effectcraft_render=trace`, `wgpu_core=info`. The control-channel token is never logged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 12d37245..20b00dae 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,8 +681,15 @@ Fixes from measured reports: too large (#390). - The viewer's Auto resolution renders the pixels the magnification needs (Full above 50 %), and frames shown below 100 % are averaged (GPU frames through mip levels, CPU frames by a whole - factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification - are not remembered between launches. + factor) instead of minified bilinearly (#417). +- Each composition keeps its Resolution (Auto, Full, Half, Third, Quarter or Custom) in the + project, as in After Effects: switching to another comp and back, or saving and reopening the + project, shows the same resolution. Changing it marks the project modified but adds no undo + step (undo and redo keep it) and keeps the comp's cached frames; projects saved before open + with Auto. View ▸ Resolution and the panel's popup run `view.res.full|half|third|quarter|auto` + and `view.res.custom {factor}`, which now work headless too; `comp.info` reports `resolution` + and `resolutionFactor` (#417). Not yet: the magnification is not saved per comp, and the + Composition Settings dialog has no Resolution popup. ### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel @@ -889,7 +896,11 @@ saved warns that what this version doesn't know is lost on saving (`file.open` r `savedBy`). Saving checks that the file reads back first, so a value JSON can't hold (NaN) fails the save with a message instead of writing a project that can never be opened (the file on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`) -XML; a file that isn't a project says so by name. +XML; a file that isn't a project says so by name. A corrupt or hostile project opens within the +limits the commands use (#788): compositions, solids and footage at most 30,000 × 30,000 px (as in +After Effects), frame rates 0.001–1000 fps, pixel aspect 0.1–10, compositions at least one frame +long and 2–64 motion-blur samples; `file.open` lists what it corrected (`repaired`) and a toast +names it. ### Update: M3.13 menu bar